Greasy Fork is available in English.
A restrained, iOS-styled plugin layer for the Discord web client.
// ==UserScript==
// @name Halcyon for Discord
// @namespace halcyon
// @version 0.6.1
// @description A restrained, iOS-styled plugin layer for the Discord web client.
// @author caitemm (mzrodyu)
// @match *://*.discord.com/*
// @run-at document-start
// @grant none
// @license GPL-3.0-or-later
// ==/UserScript==
"use strict";
var Halcyon = (() => {
// src/core/logger/index.ts
var WEIGHT = {
debug: 10,
info: 20,
warn: 30,
error: 40
};
var BADGE = {
debug: "#8E8E93",
info: "#0A84FF",
warn: "#FF9F0A",
error: "#FF453A"
};
var RING_CAPACITY = 500;
var ring = [];
var subscribers = /* @__PURE__ */ new Set();
var threshold = false ? WEIGHT.debug : WEIGHT.info;
function record(level, scope, parts) {
const entry = { time: Date.now(), level, scope, parts };
ring.push(entry);
if (ring.length > RING_CAPACITY) ring.shift();
for (const fn of subscribers) {
try {
fn(entry);
} catch {
}
}
if (WEIGHT[level] < threshold) return;
const badge = `background:${BADGE[level]};color:#fff;border-radius:4px;padding:0 6px;font-weight:600`;
const sink = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
sink(`%cHalcyon%c ${scope}`, badge, "color:inherit;font-weight:600", ...parts);
}
function logger(scope) {
return {
debug: (...p) => record("debug", scope, p),
info: (...p) => record("info", scope, p),
warn: (...p) => record("warn", scope, p),
error: (...p) => record("error", scope, p),
child: (childScope) => logger(`${scope}:${childScope}`)
};
}
function getLogHistory() {
return ring.slice();
}
function onLog(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
// src/core/modules/webpack.ts
var log = logger("modules");
var CHUNK_KEY = "webpackChunkdiscord_app";
var wpRequire;
var ready = false;
var interceptorInstalled = false;
var waiters = /* @__PURE__ */ new Set();
var sourcePatches = [];
var selfResolver = () => void 0;
function setSelfResolver(fn) {
selfResolver = fn;
globalThis.__halcyon_self__ = (id) => selfResolver(id);
}
function registerSourcePatch(patch) {
sourcePatches.push({ ...patch, applied: false, hits: 0 });
}
function getSourcePatchReport() {
return sourcePatches.map(({ pluginId, label, applied, hits }) => ({ pluginId, label, applied, hits }));
}
function installChunkInterceptor() {
if (interceptorInstalled) return;
interceptorInstalled = true;
const target = globalThis;
const existing = target[CHUNK_KEY] ?? [];
const wrapPush = (underlying) => function(...args) {
try {
instrumentChunk(args[0]);
} catch (err) {
log.error("failed to instrument chunk", err);
}
return underlying.apply(this ?? existing, args);
};
const prior = existing.push;
let currentPush = typeof prior === "function" && prior !== Array.prototype.push ? wrapPush(prior.bind(existing)) : Array.prototype.push.bind(existing);
try {
Object.defineProperty(existing, "push", {
configurable: true,
get: () => currentPush,
set: (assigned) => {
currentPush = wrapPush(assigned);
}
});
} catch (err) {
log.error("could not install chunk interceptor", err);
return;
}
target[CHUNK_KEY] = existing;
for (const chunk of existing) {
try {
instrumentChunk(chunk);
} catch {
}
}
existing.push([
[Symbol("halcyon.require")],
{},
(req) => {
wpRequire = req;
try {
wrapPendingFactories(req);
} catch (err) {
log.error("failed to wrap pre-existing factories", err);
}
}
]);
}
function wrapPendingFactories(req) {
const factories = req?.m;
if (!factories || typeof factories !== "object") return;
let wrapped = 0;
let alreadyRun = 0;
for (const id of Object.keys(factories)) {
const original = factories[id];
if (typeof original !== "function" || original.__halcyon__) continue;
if (req.c && req.c[id]) {
alreadyRun++;
continue;
}
factories[id] = wrapFactory(id, original);
wrapped++;
}
if (wrapped || alreadyRun) {
log.info(
`swept pre-existing factories: wrapped ${wrapped}, skipped ${alreadyRun} already-executed`
);
}
}
function awaitCoreReady() {
return new Promise((resolveReady) => {
installChunkInterceptor();
waitFor(
(exp) => isFluxDispatcher(exp),
() => {
if (ready) return;
ready = true;
log.info("core runtime detected");
resolveReady();
}
);
setTimeout(() => {
if (ready) return;
log.warn("core module not seen within grace period; continuing degraded");
ready = true;
resolveReady();
}, 15e3);
});
}
function instrumentChunk(chunk) {
const factories = chunk?.[1];
if (!factories || typeof factories !== "object") return;
for (const id of Object.keys(factories)) {
const original = factories[id];
if (typeof original !== "function" || original.__halcyon__) continue;
factories[id] = wrapFactory(id, original);
}
}
function wrapFactory(id, original) {
let effective;
const wrapped = function(module, exports, require2) {
if (!effective) {
const applicable = sourcePatches.filter((p) => sourceMatches(p.find, original));
effective = applicable.length ? applyPatches(id, original, applicable) : original;
}
effective.call(this, module, exports, require2);
try {
dispatchToWaiters(module);
} catch (err) {
log.error("module observer threw for", id, err);
}
};
wrapped.toString = () => original.toString();
wrapped.__halcyon__ = true;
return wrapped;
}
function applyPatches(id, original, patches) {
let code = String(original);
for (const patch of patches) {
const before = code;
const replacement = bindSelf(patch.replace, patch.pluginId);
code = patch.all ? code.replace(new RegExp(patch.match.source, ensureGlobal(patch.match.flags)), replacement) : code.replace(patch.match, replacement);
if (code === before) {
log.warn(`patch "${patch.label}" (${patch.pluginId}) matched module ${id} but changed nothing`);
continue;
}
patch.applied = true;
patch.hits++;
log.debug(`applied patch "${patch.label}" (${patch.pluginId}) to module ${id}`);
}
try {
const rebuilt = (0, eval)(`(${toFunctionExpression(code)})`);
return rebuilt;
} catch (err) {
log.error(`patched module ${id} failed to compile; using original`, err);
return original;
}
}
function toFunctionExpression(src) {
const s = src.trimStart();
if (/^(async\s+)?function[\s*(]/.test(s)) return s;
if (/^(async\s+)?(\([^)]*\)|[\w$]+)\s*=>/.test(s)) return s;
const head = s.match(/^(async\s+)?(\*\s*)?(?:\[[^\]]*\]|[\w$]+)\s*\(/);
if (head) {
const asyncKw = head[1] ? "async " : "";
const star = head[2] ? "*" : "";
return `${asyncKw}function${star}${s.slice(head[0].length - 1)}`;
}
return s;
}
function ensureGlobal(flags) {
return flags.includes("g") ? flags : flags + "g";
}
function bindSelf(replace, pluginId) {
const token = `__halcyon_self__(${JSON.stringify(pluginId)})`;
if (typeof replace === "string") {
return replace.split("$self").join(token);
}
return (...args) => replace(...args).split("$self").join(token);
}
function sourceMatches(find2, factory) {
const src = factory.toString();
return typeof find2 === "string" ? src.includes(find2) : find2.test(src);
}
var NESTED_SCAN_MAX_KEYS = 40;
function matchExport(exp, filter, meta) {
try {
if (filter(exp, meta)) return exp;
} catch {
}
if (typeof exp !== "object" && typeof exp !== "function") return void 0;
let keys;
try {
keys = Object.keys(exp);
} catch {
return void 0;
}
if (keys.length > NESTED_SCAN_MAX_KEYS) return void 0;
for (const key of keys) {
let value;
try {
value = exp[key];
} catch {
continue;
}
if (value == null || typeof value !== "object" && typeof value !== "function") continue;
try {
if (filter(value, meta)) return value;
} catch {
}
}
return void 0;
}
function dispatchToWaiters(module) {
if (!waiters.size) return;
const exp = module.exports;
if (exp == null) return;
for (const waiter of waiters) {
const hit = matchExport(exp, waiter.filter, { id: module.id, module });
if (hit !== void 0) {
waiters.delete(waiter);
waiter.resolve(hit);
}
}
}
function find(filter) {
if (!wpRequire) return void 0;
for (const id of Object.keys(wpRequire.c)) {
const module = wpRequire.c[id];
const exp = module?.exports;
if (exp == null || exp === globalThis) continue;
const hit = matchExport(exp, filter, { id, module });
if (hit !== void 0) return hit;
}
return void 0;
}
function findAll(filter) {
const out = [];
if (!wpRequire) return out;
for (const id of Object.keys(wpRequire.c)) {
const module = wpRequire.c[id];
const exp = module?.exports;
if (exp == null || exp === globalThis) continue;
const hit = matchExport(exp, filter, { id, module });
if (hit !== void 0) out.push(hit);
}
return out;
}
function findByProps(...props) {
return find((exp) => props.every((p) => exp[p] !== void 0));
}
function findByCode(...needles) {
return find((exp) => {
if (typeof exp !== "function") return false;
let src;
try {
src = Function.prototype.toString.call(exp);
} catch {
return false;
}
return needles.every((n) => src.includes(n));
});
}
function findStore(name) {
return find((exp) => exp?.getName?.() === name || exp?.constructor?.displayName === name);
}
function getAllStores() {
const fluxModule = find(
(exp) => typeof exp?.Store === "function" && typeof exp.Store.getAll === "function"
);
if (fluxModule) {
try {
const all = fluxModule.Store.getAll();
if (Array.isArray(all) && all.length > 0) return all;
} catch {
}
}
return findAll(
(exp) => typeof exp?.getName === "function" && typeof exp?.addChangeListener === "function" && // Reject Discord's answer-everything intl proxy (see UserStore).
typeof exp?.__halcyon_probe__ === "undefined"
);
}
function storeNames() {
const names = /* @__PURE__ */ new Set();
for (const store of getAllStores()) {
try {
const name = store?.getName?.();
if (typeof name === "string" && name) names.add(name);
} catch {
}
}
return [...names].sort();
}
function findStoreByName(name) {
const direct = findStore(name);
if (direct) return direct;
for (const store of getAllStores()) {
try {
if (store?.getName?.() === name || store?.constructor?.displayName === name) return store;
} catch {
}
}
return void 0;
}
function findStoreWithMethods(...methods) {
for (const store of getAllStores()) {
try {
if (methods.every((m) => typeof store?.[m] === "function")) return store;
} catch {
}
}
return void 0;
}
function waitFor(filter, callback) {
const existing = find(filter);
if (existing !== void 0) {
callback(existing);
return;
}
waiters.add({ filter, resolve: callback });
}
function lazy(filter) {
let resolved;
const get = () => resolved ??= find(filter);
return new Proxy(
{},
{
get(_t, key) {
const mod = get();
if (mod == null) return void 0;
const value = mod[key];
return typeof value === "function" ? value.bind(mod) : value;
},
has(_t, key) {
const mod = get();
return mod != null && key in mod;
}
}
);
}
function isReady() {
return ready;
}
function isFluxDispatcher(exp) {
return exp != null && typeof exp.dispatch === "function" && typeof exp.subscribe === "function" && (typeof exp._actionHandlers !== "undefined" || typeof exp._subscriptions !== "undefined" || typeof exp._waitQueue !== "undefined" || typeof exp.isDispatching === "function" || typeof exp.wait === "function");
}
function dumpFactorySource(needle, radius = 300) {
const factories = wpRequire?.m;
if (!factories) return "<webpack require not ready \u2014 open the target UI first>";
const blocks = [];
for (const id of Object.keys(factories)) {
let src;
try {
src = String(factories[id]);
} catch {
continue;
}
if (!src.includes(needle)) continue;
const slices = [];
let idx = src.indexOf(needle);
let hits = 0;
while (idx >= 0 && hits < 4) {
slices.push(src.slice(Math.max(0, idx - radius), idx + needle.length + radius));
idx = src.indexOf(needle, idx + needle.length);
hits++;
}
blocks.push(`===== module ${id} (${hits} hit${hits === 1 ? "" : "s"}) =====
${slices.join("\n ... \n")}`);
}
return blocks.length ? blocks.join("\n\n") : `<no loaded factory contains "${needle}">`;
}
function diagnoseSettings() {
const patches = getSourcePatchReport();
const dom = {
// The wrapper EmbeddedView renders — proof the section is on screen.
embedRendered: typeof document !== "undefined" && !!document.querySelector(".hc-embed"),
// Any Halcyon surface mounted at all (embed or overlay).
halcyonMounted: typeof document !== "undefined" && !!document.querySelector(".halcyon")
};
try {
let start = null;
const els = document.querySelectorAll("*");
for (let i = 0; i < els.length && !start; i++) {
const el = els[i];
const key = Object.keys(el).find((k) => k.startsWith("__reactFiber$"));
if (key) start = el[key];
}
if (!start) return JSON.stringify({ error: "no React fiber found in DOM", patches, dom }, null, 2);
let root = start;
while (root.return) root = root.return;
const srcOf = (t) => {
try {
if (typeof t === "function") return Function.prototype.toString.call(t);
if (t && typeof t === "object") {
const inner = t.type || t.render;
if (typeof inner === "function") return Function.prototype.toString.call(inner);
}
} catch {
}
return "";
};
const nameOf = (t) => t && (t.displayName || t.name) || t && t.type && (t.type.displayName || t.type.name) || "";
const queue = [root];
let walked = 0;
const buildLayoutHits = [];
const gpsHits = [];
const sidebarComps = /* @__PURE__ */ new Set();
const namedSettings = /* @__PURE__ */ new Set();
while (queue.length && walked < 4e4) {
const f = queue.shift();
walked++;
const t = f.type;
if (t && (typeof t === "function" || typeof t === "object")) {
const s = srcOf(t);
const n = nameOf(t) || "anon";
const patched = s.includes("__halcyon_self__");
if (s.includes("buildLayout")) buildLayoutHits.push({ name: n, patched });
if (s.includes("getPredicateSections")) gpsHits.push({ name: n, patched });
if (s.includes("renderSidebar") || s.includes("SETTINGS_SIDEBAR")) sidebarComps.add(n);
if (/settings/i.test(n)) namedSettings.add(n);
}
if (f.child) queue.push(f.child);
if (f.sibling) queue.push(f.sibling);
}
const layoutPatch = patches.find((p) => p.label === "user-settings-layout");
const sidebarPatch = patches.find((p) => p.label === "user-settings-sidebar");
const verdict = dom.embedRendered ? "embed rendered \u2014 Halcyon section is on screen" : layoutPatch?.applied || sidebarPatch?.applied ? "patch applied at load but section not seen \u2014 open user settings, then re-run" : "no settings patch matched this build \u2014 run dumpSource('buildLayout') and share the output";
return JSON.stringify(
{
verdict,
dom,
patches,
walked,
buildLayoutHits,
gpsHits,
sidebarComps: [...sidebarComps].slice(0, 25),
settingsNamed: [...namedSettings].slice(0, 40)
},
null,
2
);
} catch (err) {
return JSON.stringify({ error: String(err), patches, dom }, null, 2);
}
}
// src/core/common/react.ts
function lazyProxy(resolve) {
let cached2;
const get = () => cached2 ??= resolve();
return new Proxy(function() {
}, {
get: (_t, key) => get()?.[key],
set: (_t, key, value) => {
const mod = get();
if (mod) mod[key] = value;
return true;
},
has: (_t, key) => {
const mod = get();
return mod != null && key in mod;
},
ownKeys: () => Reflect.ownKeys(get() ?? {}),
getOwnPropertyDescriptor: (_t, key) => Reflect.getOwnPropertyDescriptor(get() ?? {}, key),
apply: (_t, thisArg, args) => get().apply(thisArg, args),
construct: (_t, args) => new (get())(...args)
});
}
function byFunctionProps(...props) {
return (exp) => props.every((p) => typeof exp[p] === "function") && typeof exp.__halcyon_probe__ === "undefined";
}
var React = lazyProxy(
() => find(byFunctionProps("createElement", "useState", "useEffect", "useMemo"))
);
var ReactDOM = lazyProxy(
() => find(byFunctionProps("createPortal", "flushSync")) ?? find(byFunctionProps("createPortal"))
);
function getCreateRoot() {
const mod = find(byFunctionProps("createRoot", "hydrateRoot")) ?? find(byFunctionProps("createRoot"));
return mod?.createRoot?.bind(mod);
}
function mountDetached(element, container) {
const createRoot = getCreateRoot();
if (createRoot) {
const root = createRoot(container);
root.render(element);
return () => {
try {
root.unmount();
} catch {
}
};
}
ReactDOM.render(element, container);
return () => {
try {
ReactDOM.unmountComponentAtNode(container);
} catch {
}
};
}
function getFiber(node) {
if (node == null || typeof node !== "object") return null;
try {
for (const key of Object.getOwnPropertyNames(node)) {
if (key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$")) {
return node[key];
}
}
} catch {
}
return null;
}
function getFiberPropsChain(node, maxDepth = 30) {
const out = [];
let fiber = getFiber(node);
for (let depth = 0; fiber != null && depth < maxDepth; depth++) {
try {
const props = fiber.memoizedProps ?? fiber.pendingProps;
if (props != null && typeof props === "object") out.push(props);
fiber = fiber.return;
} catch {
break;
}
}
return out;
}
var useState = (...a) => React.useState(...a);
var useEffect = (...a) => React.useEffect(...a);
var useMemo = (...a) => React.useMemo(...a);
var useRef = (...a) => React.useRef(...a);
// src/userscript/install-storage.ts
var g = globalThis;
try {
const ls = g.localStorage;
if (ls) {
const native = g.HalcyonNative ??= {};
native.storage = {
read: (key) => ls.getItem(key),
write: (key, value) => ls.setItem(key, value),
remove: (key) => ls.removeItem(key)
};
}
} catch {
}
// src/core/settings/storage.ts
var log2 = logger("settings");
var PREFIX = "halcyon:";
function selectBackend() {
const native = globalThis.HalcyonNative?.storage;
if (native && typeof native.read === "function" && typeof native.write === "function") {
return native;
}
try {
const ls = globalThis.localStorage;
if (ls) {
return {
read: (k) => ls.getItem(k),
write: (k, v) => ls.setItem(k, v),
remove: (k) => ls.removeItem(k)
};
}
} catch {
}
log2.warn("no persistent storage backend; settings will not survive a restart");
const memory = /* @__PURE__ */ new Map();
return {
read: (k) => memory.get(k) ?? null,
write: (k, v) => void memory.set(k, v),
remove: (k) => void memory.delete(k)
};
}
var backend = selectBackend();
function loadNamespace(id) {
const raw = backend.read(PREFIX + id);
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
try {
backend.write(`${PREFIX}${id}.corrupt-${stamp}`, raw);
} catch {
}
log2.warn(`stored settings for "${id}" were unreadable; reset to defaults (backup kept)`);
return {};
}
}
function saveNamespace(id, values) {
try {
backend.write(PREFIX + id, JSON.stringify(values));
} catch (err) {
log2.error(`could not persist settings for "${id}"`, err);
}
}
var hintStore;
try {
hintStore = globalThis.localStorage;
} catch {
hintStore = void 0;
}
var HINT_PREFIX = "halcyon:hint:";
function readSyncHint(id) {
try {
if (!hintStore) return void 0;
const raw = hintStore.getItem(HINT_PREFIX + id);
if (!raw) return void 0;
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : void 0;
} catch {
return void 0;
}
}
function writeSyncHint(id, values) {
try {
if (!hintStore) return;
hintStore.setItem(HINT_PREFIX + id, JSON.stringify(values));
} catch {
}
}
// src/core/runtime.ts
var log3 = logger("runtime");
var ENABLED_NS = "core.enabled";
var Runtime = class {
records = /* @__PURE__ */ new Map();
enabledMap = {};
bootPatched = /* @__PURE__ */ new Set();
listeners = /* @__PURE__ */ new Set();
prepared = false;
booted = false;
register(plugin) {
if (this.records.has(plugin.id)) {
log3.warn(`duplicate plugin id "${plugin.id}" ignored`);
return;
}
this.records.set(plugin.id, { plugin, state: "disabled" });
plugin.settings?.__bind(plugin.id);
}
registerAll(plugins2) {
for (const p of plugins2) this.register(p);
}
/**
* Synchronous, storage-independent preparation. Wires up `$self` resolution,
* registers source patches for everything that will run, and takes over
* Webpack — all of which must be in place before Discord pushes its first
* chunk, or a module can be cached in its unpatched form.
*
* The extension entry calls this ahead of its async storage handshake for
* exactly that reason; the desktop entry reaches it synchronously through
* boot(). Idempotent either way.
*/
prepare() {
if (this.prepared) return;
this.prepared = true;
setSelfResolver((id) => this.records.get(id)?.plugin);
const hint = readSyncHint(ENABLED_NS) ?? {};
const stored = loadNamespace(ENABLED_NS) ?? {};
this.enabledMap = { ...hint, ...stored };
this.registerBootPatches();
installChunkInterceptor();
}
/** Boot sequence. Call once, as early as the renderer allows. */
async boot() {
if (this.booted) return;
this.booted = true;
this.prepare();
this.enabledMap = loadNamespace(ENABLED_NS) ?? {};
writeSyncHint(ENABLED_NS, this.enabledMap);
for (const { plugin } of this.records.values()) {
plugin.settings?.__bind(plugin.id);
}
this.registerBootPatches();
await awaitCoreReady();
for (const id of this.startOrder()) {
if (this.shouldRun(id)) this.startPlugin(id);
}
this.emit();
const build = true ? "2026-07-28 06:19:35" : "dev";
log3.info(`runtime up \u2014 ${this.runningCount()} plugin(s) active (build ${build})`);
}
isEnabled(id) {
const rec = this.records.get(id);
if (!rec) return false;
if (rec.plugin.required) return true;
return this.enabledMap[id] === true;
}
enable(id) {
const rec = this.records.get(id);
if (!rec) return;
for (const dep of rec.plugin.dependencies ?? []) {
if (!this.isEnabled(dep)) this.enable(dep);
}
this.enabledMap[id] = true;
this.persistEnabledState();
if (this.booted && isReady()) this.startPlugin(id);
this.emit();
}
disable(id) {
const rec = this.records.get(id);
if (!rec) return;
if (rec.plugin.required) {
log3.warn(`"${id}" is required and cannot be disabled`);
return;
}
for (const [otherId, other] of this.records) {
if (other.plugin.dependencies?.includes(id) && this.isEnabled(otherId)) {
this.disable(otherId);
}
}
this.enabledMap[id] = false;
this.persistEnabledState();
this.stopPlugin(id);
this.emit();
}
toggle(id) {
if (this.isEnabled(id)) {
this.disable(id);
return false;
}
this.enable(id);
return true;
}
/**
* Whether a restart is needed for a plugin's source patches to match its
* current enable-state. Source patches only apply at module load, so toggling
* a patch-bearing plugin after boot cannot take full effect until relaunch.
*/
needsRestart(id) {
const rec = this.records.get(id);
if (!rec?.plugin.patches?.length) return false;
return this.isEnabled(id) !== this.bootPatched.has(id);
}
getPlugin(id) {
return this.records.get(id)?.plugin;
}
list() {
return [...this.records.values()].map(({ plugin, state, error }) => ({
id: plugin.id,
name: plugin.name,
description: plugin.description,
category: plugin.category,
authors: plugin.authors,
required: plugin.required ?? false,
hidden: plugin.hidden ?? false,
enabled: this.isEnabled(plugin.id),
state,
error,
hasSettings: plugin.settings != null,
hasPage: plugin.page != null,
needsRestart: this.needsRestart(plugin.id)
}));
}
/** Subscribe to any registry change (enable/disable/state). */
onChange(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
// --- internals -----------------------------------------------------------
shouldRun(id) {
if (!this.isEnabled(id)) return false;
const rec = this.records.get(id);
if (!rec) return false;
return (rec.plugin.dependencies ?? []).every((dep) => this.isEnabled(dep));
}
/**
* Register source patches for every plugin that should run and hasn't been
* patched yet. Safe to call more than once: the bootPatched guard keeps a
* plugin from being registered twice across prepare() and boot().
*/
registerBootPatches() {
for (const { plugin } of this.records.values()) {
if (this.shouldRun(plugin.id) && plugin.patches?.length && !this.bootPatched.has(plugin.id)) {
this.registerPatches(plugin);
this.bootPatched.add(plugin.id);
}
}
}
registerPatches(plugin) {
for (const spec of plugin.patches ?? []) {
const replacements = Array.isArray(spec.replacement) ? spec.replacement : [spec.replacement];
for (const r of replacements) {
registerSourcePatch({
pluginId: plugin.id,
label: spec.label,
find: spec.find,
match: r.match,
replace: r.replace,
all: spec.all ?? false
});
}
}
}
startPlugin(id) {
const rec = this.records.get(id);
if (!rec || rec.state === "running" || rec.state === "starting") return;
rec.state = "starting";
try {
rec.plugin.start?.();
rec.state = "running";
rec.error = void 0;
log3.debug(`started "${id}"`);
} catch (err) {
rec.state = "errored";
rec.error = err;
this.enabledMap[id] = false;
this.persistEnabledState();
log3.error(`plugin "${id}" threw during start; it has been disabled`, err);
}
this.emit();
}
stopPlugin(id) {
const rec = this.records.get(id);
if (!rec || rec.state !== "running" && rec.state !== "errored") return;
rec.state = "stopping";
try {
rec.plugin.stop?.();
log3.debug(`stopped "${id}"`);
} catch (err) {
log3.error(`plugin "${id}" threw during stop; state may be inconsistent`, err);
} finally {
rec.state = "disabled";
this.emit();
}
}
/** Topological order over dependencies so a plugin starts after its deps. */
startOrder() {
const ordered = [];
const seen = /* @__PURE__ */ new Set();
const visit = (id, trail) => {
if (seen.has(id)) return;
if (trail.has(id)) {
log3.error(`dependency cycle involving "${id}"; breaking it`);
return;
}
trail.add(id);
const rec = this.records.get(id);
for (const dep of rec?.plugin.dependencies ?? []) {
if (this.records.has(dep)) visit(dep, trail);
}
trail.delete(id);
seen.add(id);
ordered.push(id);
};
for (const id of this.records.keys()) visit(id, /* @__PURE__ */ new Set());
return ordered;
}
runningCount() {
let n = 0;
for (const rec of this.records.values()) if (rec.state === "running") n++;
return n;
}
persistEnabledState() {
saveNamespace(ENABLED_NS, this.enabledMap);
writeSyncHint(ENABLED_NS, this.enabledMap);
}
emit() {
for (const fn of this.listeners) {
try {
fn();
} catch {
}
}
}
};
var runtime = new Runtime();
// src/core/plugin.ts
var BRAND = Symbol.for("halcyon.plugin");
var ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function definePlugin(definition) {
if (!ID_PATTERN.test(definition.id)) {
throw new Error(
`Halcyon: invalid plugin id "${definition.id}" \u2014 use lowercase words separated by single dashes.`
);
}
if (!definition.authors?.length) {
throw new Error(`Halcyon: plugin "${definition.id}" must list at least one author.`);
}
return Object.assign(definition, { [BRAND]: true });
}
// src/ui/tokens.css
var tokens_default = '/*\n * Design tokens.\n *\n * Every color, size, radius, and duration used anywhere in Halcyon resolves to\n * one of these variables. Components never hardcode raw values. The palette is\n * flat by design: solid fills only, no gradients.\n *\n * Values mirror docs/ui-design-guide.md. If the two ever disagree, the guide\n * is the source of truth and this file is the bug.\n */\n\n.halcyon {\n /* Accent */\n --hc-accent: #0a84ff;\n --hc-accent-pressed: #0768cc;\n\n /* Semantic */\n --hc-red: #ff453a;\n --hc-orange: #ff9f0a;\n --hc-yellow: #ffd60a;\n --hc-green: #30d158;\n --hc-teal: #64d2ff;\n --hc-indigo: #5e5ce6;\n --hc-pink: #ff375f;\n\n /* Neutral surfaces */\n --hc-bg-primary: #000000;\n --hc-bg-secondary: #1c1c1e;\n --hc-bg-tertiary: #2c2c2e;\n --hc-bg-elevated: #2c2c2e;\n\n /* Fills */\n --hc-fill-primary: rgba(120, 120, 128, 0.36);\n --hc-fill-secondary: rgba(120, 120, 128, 0.24);\n\n /* Separators */\n --hc-separator: rgba(84, 84, 88, 0.65);\n --hc-separator-opaque: #38383a;\n\n /* Labels */\n --hc-label-primary: #ffffff;\n --hc-label-secondary: rgba(235, 235, 245, 0.6);\n --hc-label-tertiary: rgba(235, 235, 245, 0.3);\n --hc-label-quaternary: rgba(235, 235, 245, 0.16);\n\n /* Spacing (8pt grid) */\n --hc-space-1: 4px;\n --hc-space-2: 8px;\n --hc-space-3: 12px;\n --hc-space-4: 16px;\n --hc-space-5: 20px;\n --hc-space-6: 24px;\n --hc-space-8: 32px;\n --hc-space-10: 40px;\n\n /* Radii */\n --hc-radius-xs: 4px;\n --hc-radius-sm: 6px;\n --hc-radius-md: 10px;\n --hc-radius-lg: 12px;\n --hc-radius-xl: 16px;\n --hc-radius-2xl: 22px;\n --hc-radius-pill: 999px;\n\n /* Elevation */\n --hc-elev-1: 0 1px 2px rgba(0, 0, 0, 0.24);\n --hc-elev-2: 0 4px 12px rgba(0, 0, 0, 0.32);\n --hc-elev-3: 0 12px 32px rgba(0, 0, 0, 0.44);\n\n /* Type scale \u2014 sizes paired with absolute line heights */\n --hc-text-title1: 28px;\n --hc-lh-title1: 34px;\n --hc-text-title2: 22px;\n --hc-lh-title2: 28px;\n --hc-text-title3: 20px;\n --hc-lh-title3: 25px;\n --hc-text-headline: 17px;\n --hc-lh-headline: 22px;\n --hc-text-body: 17px;\n --hc-lh-body: 22px;\n --hc-text-callout: 16px;\n --hc-lh-callout: 21px;\n --hc-text-subhead: 15px;\n --hc-lh-subhead: 20px;\n --hc-text-footnote: 13px;\n --hc-lh-footnote: 18px;\n --hc-text-caption1: 12px;\n --hc-lh-caption1: 16px;\n --hc-text-caption2: 11px;\n --hc-lh-caption2: 13px;\n\n /* Motion */\n --hc-ease: cubic-bezier(0.32, 0.72, 0, 1);\n --hc-duration-fast: 200ms;\n --hc-duration-slow: 300ms;\n\n /* Font stack */\n --hc-font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",\n "PingFang SC", "Microsoft YaHei", "Segoe UI", Roboto, sans-serif;\n --hc-font-mono: "SF Mono", ui-monospace, "JetBrains Mono", "Cascadia Code",\n Menlo, Consolas, monospace;\n}\n';
// src/ui/components.css
var components_default = `/*
* Component styles.
*
* Class-based, scoped under \`.halcyon\`. All values reference tokens.css; there
* are no raw colors or sizes here. Interaction states use flat fills and
* opacity, never gradients.
*/
.halcyon,
.halcyon * {
box-sizing: border-box;
}
.halcyon {
font-family: var(--hc-font);
color: var(--hc-label-primary);
-webkit-font-smoothing: antialiased;
}
/* --- Typographic helpers ------------------------------------------------- */
.hc-title2 {
font-size: var(--hc-text-title2);
line-height: var(--hc-lh-title2);
font-weight: 700;
}
.hc-title3 {
font-size: var(--hc-text-title3);
line-height: var(--hc-lh-title3);
font-weight: 600;
}
.hc-headline {
font-size: var(--hc-text-headline);
line-height: var(--hc-lh-headline);
font-weight: 600;
}
.hc-body {
font-size: var(--hc-text-body);
line-height: var(--hc-lh-body);
font-weight: 400;
}
.hc-callout {
font-size: var(--hc-text-callout);
line-height: var(--hc-lh-callout);
}
.hc-footnote {
font-size: var(--hc-text-footnote);
line-height: var(--hc-lh-footnote);
color: var(--hc-label-secondary);
}
.hc-muted {
color: var(--hc-label-secondary);
}
/* --- Button -------------------------------------------------------------- */
.hc-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--hc-space-2);
border: none;
cursor: pointer;
font-family: inherit;
font-size: var(--hc-text-body);
line-height: var(--hc-lh-body);
font-weight: 600;
border-radius: var(--hc-radius-md);
padding: 0 var(--hc-space-4);
height: 40px;
transition: background-color var(--hc-duration-fast) var(--hc-ease),
opacity var(--hc-duration-fast) var(--hc-ease),
transform var(--hc-duration-fast) var(--hc-ease);
user-select: none;
white-space: nowrap;
}
.hc-btn:active {
transform: scale(0.98);
}
.hc-btn:disabled {
opacity: 0.4;
cursor: default;
transform: none;
}
.hc-btn--sm {
height: 32px;
font-size: var(--hc-text-subhead);
padding: 0 var(--hc-space-3);
}
.hc-btn--lg {
height: 50px;
border-radius: var(--hc-radius-lg);
}
.hc-btn--primary {
background: var(--hc-accent);
color: #ffffff;
}
.hc-btn--primary:hover:not(:disabled) {
background: var(--hc-accent-pressed);
}
.hc-btn--secondary {
background: var(--hc-fill-primary);
color: var(--hc-label-primary);
}
.hc-btn--secondary:hover:not(:disabled) {
background: var(--hc-fill-secondary);
}
.hc-btn--plain {
background: transparent;
color: var(--hc-accent);
padding-left: var(--hc-space-2);
padding-right: var(--hc-space-2);
}
.hc-btn--plain:hover:not(:disabled) {
background: var(--hc-fill-secondary);
}
.hc-btn--destructive {
background: transparent;
color: var(--hc-red);
}
.hc-btn--destructive:hover:not(:disabled) {
background: rgba(255, 69, 58, 0.16);
}
/* --- Toggle -------------------------------------------------------------- */
.hc-toggle {
position: relative;
flex: none;
width: 51px;
height: 31px;
border-radius: var(--hc-radius-pill);
background: var(--hc-fill-secondary);
border: none;
cursor: pointer;
padding: 0;
transition: background-color var(--hc-duration-fast) var(--hc-ease);
}
.hc-toggle[data-on="true"] {
background: var(--hc-green);
}
.hc-toggle:disabled {
opacity: 0.4;
cursor: default;
}
.hc-toggle__knob {
position: absolute;
top: 2px;
left: 2px;
width: 27px;
height: 27px;
border-radius: 50%;
background: #ffffff;
box-shadow: var(--hc-elev-1);
transition: transform var(--hc-duration-fast) var(--hc-ease);
}
.hc-toggle[data-on="true"] .hc-toggle__knob {
transform: translateX(20px);
}
/* --- Section ------------------------------------------------------------- */
.hc-section {
margin-top: var(--hc-space-6);
}
.hc-section:first-child {
margin-top: 0;
}
.hc-section__title {
font-size: var(--hc-text-subhead);
line-height: var(--hc-lh-subhead);
color: var(--hc-label-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0 var(--hc-space-4);
margin-bottom: var(--hc-space-2);
}
.hc-section__body {
background: var(--hc-bg-secondary);
border-radius: var(--hc-radius-lg);
overflow: hidden;
}
.hc-section__note {
font-size: var(--hc-text-footnote);
line-height: var(--hc-lh-footnote);
color: var(--hc-label-secondary);
padding: var(--hc-space-2) var(--hc-space-4) 0;
}
/* --- List row ------------------------------------------------------------ */
.hc-row {
display: flex;
align-items: center;
gap: var(--hc-space-3);
min-height: 44px;
padding: var(--hc-space-2) var(--hc-space-4);
position: relative;
}
.hc-row + .hc-row::before {
content: "";
position: absolute;
top: 0;
left: 56px;
right: 0;
height: 1px;
background: var(--hc-separator);
transform: scaleY(0.5);
}
.hc-row--button {
cursor: pointer;
transition: background-color var(--hc-duration-fast) var(--hc-ease);
}
.hc-row--button:hover {
background: var(--hc-fill-secondary);
}
.hc-row--button:active {
background: var(--hc-fill-primary);
}
.hc-row__icon {
flex: none;
width: 28px;
height: 28px;
border-radius: var(--hc-radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
}
.hc-row__text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.hc-row__title {
font-size: var(--hc-text-body);
line-height: var(--hc-lh-body);
color: var(--hc-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hc-row__subtitle {
font-size: var(--hc-text-footnote);
line-height: var(--hc-lh-footnote);
color: var(--hc-label-secondary);
}
.hc-row__accessory {
flex: none;
display: flex;
align-items: center;
gap: var(--hc-space-2);
color: var(--hc-label-secondary);
}
.hc-row__chevron {
color: var(--hc-label-tertiary);
}
/* --- Text input ---------------------------------------------------------- */
.hc-input {
display: block;
width: 100%;
height: 40px;
background: var(--hc-fill-primary);
border: 2px solid transparent;
border-radius: var(--hc-radius-md);
padding: 0 var(--hc-space-3);
color: var(--hc-label-primary);
font-family: inherit;
font-size: var(--hc-text-callout);
line-height: var(--hc-lh-callout);
outline: none;
transition: border-color var(--hc-duration-fast) var(--hc-ease);
}
.hc-input::placeholder {
color: var(--hc-label-tertiary);
}
.hc-input:focus {
border-color: var(--hc-accent);
}
/* --- Number stepper ------------------------------------------------------ */
.hc-stepper {
display: inline-flex;
align-items: center;
background: var(--hc-fill-primary);
border-radius: var(--hc-radius-md);
overflow: hidden;
}
.hc-stepper__btn {
width: 36px;
height: 32px;
border: none;
background: transparent;
color: var(--hc-label-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color var(--hc-duration-fast) var(--hc-ease);
}
.hc-stepper__btn:hover:not(:disabled) {
background: var(--hc-fill-secondary);
}
.hc-stepper__btn:disabled {
color: var(--hc-label-quaternary);
cursor: default;
}
.hc-stepper__value {
min-width: 44px;
text-align: center;
font-size: var(--hc-text-callout);
font-variant-numeric: tabular-nums;
color: var(--hc-label-primary);
}
/* --- Select -------------------------------------------------------------- */
/* Self-drawn dropdown: pill button + floating iOS-style menu sheet. */
.hc-select {
position: relative;
display: inline-block;
}
.hc-select__button {
display: inline-flex;
align-items: center;
gap: var(--hc-space-2);
height: 32px;
background: var(--hc-fill-primary);
border: none;
border-radius: var(--hc-radius-md);
color: var(--hc-label-primary);
font-family: inherit;
font-size: var(--hc-text-callout);
padding: 0 var(--hc-space-3);
cursor: pointer;
outline: none;
white-space: nowrap;
}
.hc-select__button:hover {
background: var(--hc-fill-secondary);
}
.hc-select__button:focus-visible {
box-shadow: 0 0 0 2px var(--hc-accent);
}
.hc-select__chevron {
color: var(--hc-label-tertiary);
transition: transform 0.15s ease;
}
.hc-select__chevron[data-open="true"] {
transform: rotate(180deg);
}
.hc-select__menu {
/* Positioned by its portal wrapper (fixed, anchored to the button). */
max-height: 280px;
overflow-y: auto;
padding: var(--hc-space-1);
background: var(--hc-bg-elevated, #2c2c2e);
border-radius: var(--hc-radius-lg, 12px);
box-shadow:
0 0 0 0.5px rgba(255, 255, 255, 0.08),
0 10px 32px rgba(0, 0, 0, 0.45);
animation: hc-select-pop 0.14s ease;
}
@keyframes hc-select-pop {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
.hc-select__option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--hc-space-3);
width: 100%;
border: none;
background: none;
border-radius: var(--hc-radius-md);
color: var(--hc-label-primary);
font-family: inherit;
font-size: var(--hc-text-callout);
text-align: left;
padding: 7px var(--hc-space-3);
cursor: pointer;
white-space: nowrap;
}
.hc-select__option[data-active="true"] {
background: var(--hc-fill-primary);
}
.hc-select__option[data-selected="true"] {
color: var(--hc-accent);
}
.hc-select__check {
flex: none;
color: var(--hc-accent);
}
/* --- String list --------------------------------------------------------- */
.hc-strlist {
display: flex;
flex-direction: column;
gap: var(--hc-space-2);
padding: var(--hc-space-2) var(--hc-space-4) var(--hc-space-3);
}
.hc-strlist__item {
display: flex;
align-items: center;
gap: var(--hc-space-2);
}
.hc-strlist__add {
display: flex;
gap: var(--hc-space-2);
}
.hc-iconbtn {
flex: none;
width: 32px;
height: 32px;
border-radius: var(--hc-radius-md);
border: none;
background: var(--hc-fill-primary);
color: var(--hc-label-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color var(--hc-duration-fast) var(--hc-ease),
color var(--hc-duration-fast) var(--hc-ease);
}
.hc-iconbtn:hover {
background: var(--hc-fill-secondary);
}
.hc-iconbtn--danger:hover {
color: var(--hc-red);
}
/* --- Badge --------------------------------------------------------------- */
.hc-badge {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 var(--hc-space-2);
border-radius: var(--hc-radius-pill);
font-size: var(--hc-text-caption1);
line-height: var(--hc-lh-caption1);
font-weight: 600;
}
.hc-badge[data-tone="neutral"] {
background: var(--hc-fill-secondary);
color: var(--hc-label-secondary);
}
.hc-badge[data-tone="accent"] {
background: rgba(10, 132, 255, 0.2);
color: var(--hc-accent);
}
.hc-badge[data-tone="green"] {
background: rgba(48, 209, 88, 0.2);
color: var(--hc-green);
}
.hc-badge[data-tone="red"] {
background: rgba(255, 69, 58, 0.2);
color: var(--hc-red);
}
.hc-badge[data-tone="orange"] {
background: rgba(255, 159, 10, 0.2);
color: var(--hc-orange);
}
/* --- Empty state --------------------------------------------------------- */
.hc-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--hc-space-10) var(--hc-space-6);
color: var(--hc-label-tertiary);
}
.hc-empty__title {
font-size: var(--hc-text-headline);
line-height: var(--hc-lh-headline);
font-weight: 600;
color: var(--hc-label-secondary);
margin-top: var(--hc-space-4);
}
.hc-empty__subtitle {
font-size: var(--hc-text-callout);
line-height: var(--hc-lh-callout);
color: var(--hc-label-tertiary);
margin-top: var(--hc-space-2);
max-width: 320px;
}
/* --- Overlay + panel (fallback entry point) ------------------------------ */
.hc-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
animation: hc-fade var(--hc-duration-slow) var(--hc-ease);
}
.hc-panel {
width: min(900px, 92vw);
height: min(720px, 88vh);
background: var(--hc-bg-primary);
border-radius: var(--hc-radius-xl);
box-shadow: var(--hc-elev-3);
display: flex;
overflow: hidden;
animation: hc-rise var(--hc-duration-slow) var(--hc-ease);
}
.hc-panel__sidebar {
width: 220px;
flex: none;
background: var(--hc-bg-secondary);
border-right: 1px solid var(--hc-separator-opaque);
padding: var(--hc-space-4) var(--hc-space-2);
display: flex;
flex-direction: column;
gap: 2px;
}
.hc-panel__brand {
display: flex;
align-items: center;
gap: var(--hc-space-2);
padding: var(--hc-space-2) var(--hc-space-3) var(--hc-space-4);
color: var(--hc-label-primary);
}
.hc-panel__brand-name {
font-size: var(--hc-text-headline);
font-weight: 700;
}
.hc-navitem {
display: flex;
align-items: center;
gap: var(--hc-space-2);
padding: var(--hc-space-2) var(--hc-space-3);
border-radius: var(--hc-radius-md);
color: var(--hc-label-secondary);
cursor: pointer;
font-size: var(--hc-text-callout);
border: none;
background: transparent;
text-align: left;
width: 100%;
transition: background-color var(--hc-duration-fast) var(--hc-ease),
color var(--hc-duration-fast) var(--hc-ease);
}
.hc-navitem:hover {
background: var(--hc-fill-secondary);
color: var(--hc-label-primary);
}
.hc-navitem[data-active="true"] {
background: var(--hc-fill-primary);
color: var(--hc-label-primary);
}
.hc-panel__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.hc-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--hc-space-5) var(--hc-space-6) var(--hc-space-4);
border-bottom: 1px solid var(--hc-separator-opaque);
}
.hc-panel__scroll {
flex: 1;
overflow-y: auto;
padding: var(--hc-space-5) var(--hc-space-6) var(--hc-space-8);
}
.hc-embed {
/* When embedded in Discord's own settings pane rather than the overlay. */
padding: var(--hc-space-2) 0 var(--hc-space-8);
}
@keyframes hc-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes hc-rise {
from {
opacity: 0;
transform: translateY(8px) scale(0.99);
}
to {
opacity: 1;
transform: none;
}
}
/* Respect the OS "reduce motion" preference. */
@media (prefers-reduced-motion: reduce) {
.hc-overlay,
.hc-panel,
.hc-btn,
.hc-toggle__knob {
animation: none;
transition: none;
}
}
/* --- Setting cells (schema-driven form) ---------------------------------- */
.hc-cell {
padding: var(--hc-space-2) var(--hc-space-4);
position: relative;
}
.hc-cell + .hc-cell::before {
content: "";
position: absolute;
top: 0;
left: var(--hc-space-4);
right: 0;
height: 1px;
background: var(--hc-separator);
transform: scaleY(0.5);
}
.hc-cell--row {
display: flex;
align-items: center;
gap: var(--hc-space-3);
min-height: 44px;
}
.hc-cell__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.hc-cell__label {
font-size: var(--hc-text-body);
line-height: var(--hc-lh-body);
color: var(--hc-label-primary);
}
.hc-cell__desc {
font-size: var(--hc-text-footnote);
line-height: var(--hc-lh-footnote);
color: var(--hc-label-secondary);
}
.hc-cell__control {
flex: none;
}
.hc-cell__stacked {
padding-top: var(--hc-space-2);
}
/* --- Toolbar (search + actions) ------------------------------------------ */
.hc-toolbar {
display: flex;
align-items: center;
gap: var(--hc-space-3);
margin-bottom: var(--hc-space-4);
}
/* --- Server-rail button (injected under Discord's home/DM button) -------- */
/* Styled to read as a native rail icon: a 48px rounded square (not a circle)
like Discord's own home button, on the same graphite fill, with a muted grey
glyph. On hover it snaps to the brand color and squares off a touch \u2014 exactly
how Discord's guild pills animate \u2014 so it belongs in the rail instead of
standing out as a bright foreign blob. */
.hc-rail-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 4px 0;
}
.hc-rail-btn {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
padding: 0;
border: none;
background: #000;
color: var(--interactive-normal, #b5bac1);
cursor: pointer;
border-radius: 16px;
transition: border-radius var(--hc-duration-fast) var(--hc-ease),
background-color var(--hc-duration-fast) var(--hc-ease),
color var(--hc-duration-fast) var(--hc-ease);
}
.hc-rail-btn:hover {
border-radius: 14px;
background: var(--brand-experiment, var(--hc-accent, #5865f2));
color: #fff;
}
.hc-rail-btn:active {
border-radius: 12px;
}
.hc-search {
flex: 1;
display: flex;
align-items: center;
gap: var(--hc-space-2);
height: 36px;
padding: 0 var(--hc-space-3);
background: var(--hc-fill-primary);
border-radius: var(--hc-radius-md);
color: var(--hc-label-tertiary);
}
.hc-search input {
flex: 1;
border: none;
background: transparent;
outline: none;
color: var(--hc-label-primary);
font-family: inherit;
font-size: var(--hc-text-callout);
}
.hc-search input::placeholder {
color: var(--hc-label-tertiary);
}
/* --- Plugin detail header ------------------------------------------------ */
.hc-back {
display: inline-flex;
align-items: center;
gap: var(--hc-space-1);
background: transparent;
border: none;
color: var(--hc-accent);
cursor: pointer;
font-family: inherit;
font-size: var(--hc-text-callout);
padding: var(--hc-space-1) var(--hc-space-1) var(--hc-space-1) 0;
margin-bottom: var(--hc-space-4);
}
.hc-detail-head {
display: flex;
align-items: flex-start;
gap: var(--hc-space-3);
margin-bottom: var(--hc-space-5);
}
.hc-detail-head__icon {
flex: none;
width: 44px;
height: 44px;
border-radius: var(--hc-radius-lg);
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
}
.hc-detail-head__text {
flex: 1;
min-width: 0;
}
.hc-detail-head__name {
font-size: var(--hc-text-title3);
line-height: var(--hc-lh-title3);
font-weight: 600;
}
.hc-detail-head__desc {
font-size: var(--hc-text-callout);
line-height: var(--hc-lh-callout);
color: var(--hc-label-secondary);
margin-top: 2px;
}
.hc-detail-head__meta {
font-size: var(--hc-text-footnote);
color: var(--hc-label-tertiary);
margin-top: var(--hc-space-2);
}
/* --- Log viewer ---------------------------------------------------------- */
.hc-logs {
font-family: var(--hc-font-mono);
font-size: var(--hc-text-footnote);
line-height: 1.7;
background: var(--hc-bg-secondary);
border-radius: var(--hc-radius-lg);
padding: var(--hc-space-3);
overflow-x: auto;
}
.hc-logline {
display: flex;
gap: var(--hc-space-2);
white-space: pre;
padding: 1px 0;
}
.hc-logline__time {
color: var(--hc-label-tertiary);
flex: none;
}
.hc-logline__scope {
color: var(--hc-label-secondary);
flex: none;
}
.hc-logline__msg {
color: var(--hc-label-primary);
white-space: pre-wrap;
word-break: break-word;
}
.hc-logline[data-level="warn"] .hc-logline__msg {
color: var(--hc-orange);
}
.hc-logline[data-level="error"] .hc-logline__msg {
color: var(--hc-red);
}
.hc-logline[data-level="debug"] .hc-logline__msg {
color: var(--hc-label-secondary);
}
/* --- About --------------------------------------------------------------- */
.hc-about__row {
display: flex;
justify-content: space-between;
align-items: center;
}
.hc-about__value {
color: var(--hc-label-secondary);
font-variant-numeric: tabular-nums;
}
/* --- Generic vertical rhythm --------------------------------------------- */
.hc-stack > * + * {
margin-top: var(--hc-space-4);
}
.hc-inline-note {
display: flex;
align-items: center;
gap: var(--hc-space-2);
color: var(--hc-orange);
font-size: var(--hc-text-footnote);
}
.hc-inline-note--danger {
color: var(--hc-red);
}
/* --- Detail head toggle stays top-aligned with the icon ------------------ */
.hc-detail-head > span {
flex: none;
padding-top: var(--hc-space-1);
}
/* --- About hero ---------------------------------------------------------- */
.hc-about-hero {
display: flex;
align-items: center;
gap: var(--hc-space-3);
padding: var(--hc-space-2) 0 var(--hc-space-4);
color: var(--hc-label-primary);
}
.hc-about-hero__name {
font-size: var(--hc-text-title2);
line-height: var(--hc-lh-title2);
font-weight: 700;
}
.hc-about-hero__ver {
font-size: var(--hc-text-footnote);
line-height: var(--hc-lh-footnote);
color: var(--hc-label-secondary);
}
/* --- Tabs (used by plugin pages) ----------------------------------------- */
.hc-tabs {
display: flex;
align-items: center;
gap: var(--hc-space-2);
margin-bottom: var(--hc-space-4);
}
.hc-tabs__spacer {
flex: 1;
}
.hc-tab {
display: inline-flex;
align-items: center;
gap: var(--hc-space-2);
height: 32px;
padding: 0 var(--hc-space-3);
border: none;
border-radius: var(--hc-radius-md);
background: transparent;
color: var(--hc-label-secondary);
cursor: pointer;
font-family: inherit;
font-size: var(--hc-text-subhead);
font-weight: 600;
transition: background-color var(--hc-duration-fast) var(--hc-ease),
color var(--hc-duration-fast) var(--hc-ease);
}
.hc-tab:hover {
color: var(--hc-label-primary);
}
.hc-tab[data-active="true"] {
background: var(--hc-fill-primary);
color: var(--hc-label-primary);
}
/* --- Save bar --------------------------------------------------------------- */
.hc-savebar {
position: sticky;
bottom: var(--hc-space-3);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--hc-space-4);
margin-top: var(--hc-space-4);
padding: var(--hc-space-2) var(--hc-space-2) var(--hc-space-2) var(--hc-space-4);
background: var(--hc-bg-elevated, #2c2c2e);
border-radius: var(--hc-radius-lg);
box-shadow:
0 0 0 0.5px rgba(255, 255, 255, 0.08),
0 8px 24px rgba(0, 0, 0, 0.35);
animation: hc-select-pop 0.14s ease;
}
.hc-savebar__label {
font-size: var(--hc-text-subhead);
color: var(--hc-label-secondary);
}
.hc-savebar__actions {
display: flex;
gap: var(--hc-space-2);
flex: none;
}
/* --- Segmented control ------------------------------------------------------ */
.hc-segment {
display: flex;
gap: 2px;
padding: 2px;
margin-bottom: var(--hc-space-4);
background: var(--hc-fill-primary);
border-radius: var(--hc-radius-md);
width: fit-content;
}
.hc-segment__item {
border: none;
background: transparent;
color: var(--hc-label-secondary);
font-family: inherit;
font-size: var(--hc-text-subhead);
font-weight: 600;
height: 28px;
padding: 0 var(--hc-space-4);
border-radius: calc(var(--hc-radius-md) - 2px);
cursor: pointer;
transition: background-color var(--hc-duration-fast) var(--hc-ease),
color var(--hc-duration-fast) var(--hc-ease);
}
.hc-segment__item:hover {
color: var(--hc-label-primary);
}
.hc-segment__item[data-active="true"] {
background: var(--hc-bg-elevated, #2c2c2e);
color: var(--hc-label-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
/* --- Pager ----------------------------------------------------------------- */
.hc-pager {
display: flex;
align-items: center;
justify-content: center;
gap: var(--hc-space-3);
margin-top: var(--hc-space-4);
}
.hc-pager__label {
font-size: var(--hc-text-footnote);
color: var(--hc-label-secondary);
font-variant-numeric: tabular-nums;
min-width: 96px;
text-align: center;
}
.hc-pager .hc-tab:disabled {
opacity: 0.4;
cursor: default;
}
/* --- Captured message entries -------------------------------------------- */
.hc-msglist {
display: flex;
flex-direction: column;
gap: var(--hc-space-2);
}
.hc-msg {
background: var(--hc-bg-secondary);
border-radius: var(--hc-radius-lg);
padding: var(--hc-space-3) var(--hc-space-4);
border-left: 2px solid var(--hc-red);
}
.hc-msg__head {
display: flex;
align-items: center;
gap: var(--hc-space-2);
margin-bottom: var(--hc-space-1);
}
.hc-msg__author {
font-size: var(--hc-text-subhead);
font-weight: 600;
color: var(--hc-label-primary);
}
.hc-msg__where {
font-size: var(--hc-text-footnote);
color: var(--hc-label-secondary);
}
.hc-msg__guild {
color: var(--hc-label-secondary);
font-weight: 600;
}
.hc-msg__sep {
color: var(--hc-label-tertiary);
margin: 0 4px;
}
.hc-msg__time {
margin-left: auto;
font-size: var(--hc-text-caption1);
color: var(--hc-label-tertiary);
font-variant-numeric: tabular-nums;
}
/* Jump-to-message action, pinned to the right of each row's header. Keeps the
* header on one line and doesn't steal the space the time claims via
* margin-left:auto (which already pushes both to the right edge). */
.hc-msg__jump {
flex: none;
margin-left: var(--hc-space-2);
}
.hc-msg__body {
font-size: var(--hc-text-callout);
line-height: var(--hc-lh-callout);
color: var(--hc-label-primary);
white-space: pre-wrap;
word-break: break-word;
}
.hc-msg__empty {
color: var(--hc-label-tertiary);
font-style: italic;
}
.hc-msg__meta {
margin-top: var(--hc-space-1);
font-size: var(--hc-text-footnote);
color: var(--hc-label-secondary);
}
/* Attachment thumbnails. Constrained so wide/tall media never spills past the
* message card \u2014 a single image caps at the content width, and the row wraps
* when there are several. */
.hc-msg__media {
display: flex;
flex-wrap: wrap;
gap: var(--hc-space-2);
margin-top: var(--hc-space-2);
min-width: 0;
}
.hc-msg__media a {
color: var(--hc-accent);
font-size: var(--hc-text-footnote);
word-break: break-all;
}
.hc-msg__thumb {
max-width: 100%;
max-height: 240px;
width: auto;
height: auto;
object-fit: contain;
border-radius: var(--hc-radius-md);
background: var(--hc-fill-secondary);
}
/* Inline custom emoji, sized to the surrounding text like Discord's own. */
.hc-emoji {
display: inline-block;
width: 1.375em;
height: 1.375em;
margin: 0 1px;
object-fit: contain;
vertical-align: bottom;
}
.hc-msg__versions {
display: flex;
flex-direction: column;
gap: var(--hc-space-1);
}
.hc-msg__version {
display: flex;
gap: var(--hc-space-2);
font-size: var(--hc-text-callout);
line-height: var(--hc-lh-callout);
}
.hc-msg__vtag {
flex: none;
color: var(--hc-label-tertiary);
font-variant-numeric: tabular-nums;
font-size: var(--hc-text-footnote);
padding-top: 2px;
}
.hc-msg__vbody {
color: var(--hc-label-primary);
white-space: pre-wrap;
word-break: break-word;
}
/* The \`edited\` tone reuses the orange rule via a modifier. */
.hc-msg--edited {
border-left-color: var(--hc-orange);
}
/* --- message-logger status banner ---------------------------------------- *
* A compact warning on the log page, shown only when at least one of the
* plugin's source patches failed to match the running Discord build. Inside
* the .halcyon overlay/embed, so tokens are used throughout. Amber tone: the
* feature isn't broken \u2014 records still land in the list below \u2014 but the
* in-chat red row is off, and this is the only place a non-console user will
* see that. */
.hc-mlog-warn {
border: 1px solid rgba(224, 165, 63, 0.35);
background: rgba(224, 165, 63, 0.08);
border-radius: var(--hc-radius-md);
padding: var(--hc-space-3) var(--hc-space-4);
margin: var(--hc-space-3) 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.hc-mlog-warn__title {
font-size: var(--hc-text-subhead);
font-weight: 600;
color: #e0a53f;
}
.hc-mlog-warn__detail {
font-size: var(--hc-text-footnote);
color: var(--hc-label-secondary);
line-height: var(--hc-lh-footnote);
}
.hc-mlog-warn__list {
margin: 2px 0 0;
padding-left: 18px;
font-size: var(--hc-text-footnote);
color: var(--hc-label-secondary);
font-variant-numeric: tabular-nums;
}
/* --- Deleted message (in-chat) ------------------------------------------- */
/*
* Applied to Discord's own message row when a deleted message is kept in place.
* These live outside the .halcyon scope on purpose \u2014 they decorate Discord
* elements \u2014 so literal values, no tokens.
*
* The row itself only carries the stable .hc-deleted hook; the chosen style is
* a class on <html> (hc-mlog-<style>). Splitting them lets a style change take
* effect immediately \u2014 swap the root class and every kept message updates \u2014
* instead of the pick only landing on rows Discord repaints after the change.
*/
/* Style: red tint (default) \u2014 flat red wash + left bar. */
.hc-mlog-tint .hc-deleted {
background-color: rgba(255, 69, 58, 0.1);
box-shadow: inset 2px 0 0 #ff453a;
}
/* Style: red text \u2014 content turns red, no background. */
.hc-mlog-text .hc-deleted [class*="messageContent"],
.hc-mlog-text .hc-deleted [class*="contents"] > div:not([class*="header"]) {
color: #f04747 !important;
}
.hc-mlog-text .hc-deleted [class*="messageContent"] a {
color: #ff6b6b !important;
}
/* Style: ghost \u2014 the whole row fades. */
.hc-mlog-ghost .hc-deleted {
opacity: 0.45;
filter: saturate(0.6);
}
/* Style: strike \u2014 red strikethrough over the text. */
.hc-mlog-strike .hc-deleted [class*="messageContent"] {
text-decoration: line-through;
text-decoration-color: rgba(255, 69, 58, 0.7);
text-decoration-thickness: 1.5px;
}
.hc-mlog-strike .hc-deleted {
box-shadow: inset 2px 0 0 rgba(255, 69, 58, 0.5);
}
/* "This message was deleted (\u2026)": marker row under the content. One base
* class plus a look modifier chosen in settings. */
.hc-deleted-marker {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
font-size: 0.8125rem;
line-height: 1.2;
color: #f04747;
user-select: none;
}
.hc-deleted-marker__icon {
flex: none;
}
/* Look: badge \u2014 pill-shaped chip on its OWN line. It used \`display: inline-flex\`,
* which let the pill run inline with the message text so the two never wrapped
* ("\u4E0D\u4F1A\u6362\u884C"). Inheriting the base \`display: flex\` makes it block-level (its own
* line); \`width: fit-content\` keeps the pill only as wide as its label, and
* \`max-width: 100%\` stops a long label from overflowing the row. */
.hc-deleted-marker--badge {
width: fit-content;
max-width: 100%;
background: rgba(255, 69, 58, 0.12);
border-radius: 9999px;
padding: 2px 10px;
margin-top: 4px;
}
/* Look: quote \u2014 indented behind a red bar, like a blockquote. */
.hc-deleted-marker--quote {
border-left: 3px solid rgba(255, 69, 58, 0.7);
padding-left: 8px;
margin-top: 4px;
color: rgba(240, 71, 71, 0.85);
}
/* Tone: edited \u2014 same marker layout, calmer amber so an edit doesn't read as a
* deletion. Overrides the red the delete marker uses. */
.hc-deleted-marker--edited {
color: #e0a53f;
}
.hc-deleted-marker--edited.hc-deleted-marker--badge {
background: rgba(224, 165, 63, 0.14);
}
.hc-deleted-marker--edited.hc-deleted-marker--quote {
border-left-color: rgba(224, 165, 63, 0.7);
color: rgba(224, 165, 63, 0.9);
}
/* --- Username next to nickname (show-username plugin) --------------------- */
/*
* Appended inside Discord's message header, so literal values, no tokens.
* One base class plus a per-style modifier chosen in the plugin's settings.
*/
.hc-username {
font-size: 0.75rem;
font-weight: 500;
vertical-align: baseline;
}
.hc-username--muted {
color: var(--text-muted, #949ba4);
}
.hc-username--pill {
color: var(--text-muted, #949ba4);
background: rgba(128, 132, 142, 0.16);
border-radius: 9999px;
padding: 0 6px;
line-height: 1.35;
display: inline-block;
}
.hc-username--at {
color: #949cf7;
}
.hc-username--paren {
color: var(--text-muted, #949ba4);
font-weight: 400;
}
/* --- Inline edit history (in-chat) ---------------------------------------- */
/*
* Old versions of an edited message, rendered above the current content by the
* message-logger content patch. Like .hc-deleted this decorates Discord's own
* DOM, so literal values, no tokens. The base class only handles wrapping; a
* per-style modifier (chosen in settings) sets the look. MessageExtras re-reads
* the modifier on every render, so changing the style applies live.
*/
.hc-edit-history__version {
word-break: break-word;
white-space: pre-wrap;
}
/* Per-version edit time, shown inline at the end of each old-version line.
* Muted and compact; opacity keeps it tied to whatever the version style is,
* and text-decoration:none stops the strike style from striking the time. */
.hc-edit-history__time {
margin-left: 6px;
font-size: 0.72em;
opacity: 0.55;
white-space: nowrap;
font-variant-numeric: tabular-nums;
text-decoration: none;
vertical-align: baseline;
}
/* The old-version line mirrors the deleted-message style (tint/text/ghost/
* strike) so both share one setting; strike stays its natural default look. */
/* Style: red strikethrough \u2014 struck out in red, like removed text. */
.hc-edit-history__version--strike {
color: rgba(255, 69, 58, 0.75);
text-decoration: line-through;
text-decoration-color: rgba(255, 69, 58, 0.4);
}
/* Style: red text \u2014 red, no strikethrough. */
.hc-edit-history__version--text {
color: rgba(255, 69, 58, 0.85);
}
/* Style: ghost \u2014 faded out, keeps the normal text color. */
.hc-edit-history__version--ghost {
opacity: 0.45;
filter: saturate(0.6);
}
/* Style: tint \u2014 red wash + left bar, as a quote-like block on the line. */
.hc-edit-history__version--tint {
background-color: rgba(255, 69, 58, 0.1);
box-shadow: inset 2px 0 0 #ff453a;
padding: 1px 6px 1px 8px;
border-radius: 3px;
}
/* \u2500\u2500 message-cleaner page \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
* The self-message cleaner's operate surface. Scope/confirm reuse .hc-section
* and .hc-cell; these rules cover the action bar, the live status line, the
* preview list, and the stat readout. Decorates Halcyon's own panel, so every
* value is a token. */
.hc-cleaner__actions {
display: flex;
gap: var(--hc-space-3);
margin: var(--hc-space-4) 0;
}
.hc-cleaner__actions .hc-btn {
flex: 1;
}
.hc-cleaner__status {
margin: var(--hc-space-3) 0;
padding: var(--hc-space-3) var(--hc-space-4);
background: var(--hc-fill-secondary);
border-radius: var(--hc-radius-md);
}
.hc-cleaner__status-state {
font-size: var(--hc-text-subhead);
font-weight: 600;
color: var(--hc-label-primary);
}
.hc-cleaner__status-detail {
margin-top: 2px;
font-size: var(--hc-text-footnote);
color: var(--hc-label-secondary);
word-break: break-word;
}
.hc-cleaner__list {
display: flex;
flex-direction: column;
}
.hc-cleaner__item {
display: flex;
gap: var(--hc-space-3);
padding: var(--hc-space-2) var(--hc-space-4);
font-size: var(--hc-text-footnote);
border-bottom: 1px solid var(--hc-separator);
}
.hc-cleaner__item:last-child {
border-bottom: none;
}
.hc-cleaner__item-time {
flex-shrink: 0;
color: var(--hc-accent);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.hc-cleaner__item-text {
color: var(--hc-label-primary);
word-break: break-word;
}
.hc-cleaner__more {
padding: var(--hc-space-2) var(--hc-space-4);
font-size: var(--hc-text-caption1);
color: var(--hc-label-tertiary);
}
.hc-cleaner__stat {
display: flex;
justify-content: center;
align-items: baseline;
gap: var(--hc-space-2);
}
.hc-cleaner__stat-num {
font-size: var(--hc-text-title1);
font-weight: 700;
color: var(--hc-accent);
font-variant-numeric: tabular-nums;
}
.hc-cleaner__stat-unit {
font-size: var(--hc-text-footnote);
color: var(--hc-label-secondary);
}
/* \u2500\u2500 message-cleaner picker \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
.hc-cleaner__picker-head {
display: flex;
align-items: center;
gap: var(--hc-space-3);
padding: var(--hc-space-3) var(--hc-space-4);
border-bottom: 1px solid var(--hc-separator);
}
.hc-cleaner__picker-title {
flex: 1;
text-align: center;
font-weight: 700;
font-size: var(--hc-text-subhead);
color: var(--hc-label-primary);
}
.hc-cleaner__picker-list {
display: flex;
flex-direction: column;
overflow-y: auto;
max-height: 360px;
padding: var(--hc-space-2);
}
.hc-cleaner__picker-item {
display: flex;
align-items: center;
gap: var(--hc-space-3);
padding: var(--hc-space-2) var(--hc-space-3);
border-radius: var(--hc-radius-md);
cursor: pointer;
color: var(--hc-label-primary);
transition: background var(--hc-duration-fast) var(--hc-ease);
}
.hc-cleaner__picker-item:hover {
background: var(--hc-fill-secondary);
}
.hc-cleaner__picker-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--hc-fill-primary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
font-size: var(--hc-text-subhead);
color: var(--hc-label-secondary);
}
.hc-cleaner__picker-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.hc-cleaner__picker-name {
font-size: var(--hc-text-subhead);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hc-cleaner__picker-empty {
padding: var(--hc-space-5);
text-align: center;
font-size: var(--hc-text-footnote);
color: var(--hc-label-tertiary);
}
/* \u2500\u2500 emote-cloner server picker \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
* A floating modal (mounted in its own .halcyon host over Discord) shown when
* "\u590D\u5236\u8868\u60C5/\u8D34\u7EB8\u5230\u670D\u52A1\u5668" is clicked. Sits on the shared .hc-overlay backdrop;
* the panel is compact, with a search box and a scrollable, icon-bearing list
* of the servers the account can add expressions to. Decorates Halcyon's own
* surface, so every value is a token. */
.hc-emote-picker {
width: min(440px, 92vw);
max-height: min(560px, 82vh);
background: var(--hc-bg-primary);
border-radius: var(--hc-radius-xl);
box-shadow: var(--hc-elev-3);
display: flex;
flex-direction: column;
overflow: hidden;
animation: hc-rise var(--hc-duration-slow) var(--hc-ease);
}
.hc-emote-picker__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--hc-space-3);
padding: var(--hc-space-4) var(--hc-space-4) var(--hc-space-3);
border-bottom: 1px solid var(--hc-separator-opaque);
}
.hc-emote-picker__title {
font-size: var(--hc-text-headline);
line-height: var(--hc-lh-headline);
font-weight: 600;
color: var(--hc-label-primary);
}
.hc-emote-picker__close {
flex: none;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--hc-label-secondary);
border-radius: var(--hc-radius-md);
cursor: pointer;
font-size: 15px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: background-color var(--hc-duration-fast) var(--hc-ease),
color var(--hc-duration-fast) var(--hc-ease);
}
.hc-emote-picker__close:hover {
background: var(--hc-fill-secondary);
color: var(--hc-label-primary);
}
.hc-emote-picker__search {
padding: var(--hc-space-3) var(--hc-space-4) var(--hc-space-2);
}
.hc-emote-picker__list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--hc-space-2);
display: flex;
flex-direction: column;
gap: 2px;
}
.hc-emote-picker__item {
display: flex;
align-items: center;
gap: var(--hc-space-3);
padding: var(--hc-space-2) var(--hc-space-3);
border-radius: var(--hc-radius-md);
cursor: pointer;
transition: background-color var(--hc-duration-fast) var(--hc-ease);
}
.hc-emote-picker__item:hover {
background: var(--hc-fill-secondary);
}
.hc-emote-picker__icon {
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--hc-fill-primary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
font-size: var(--hc-text-subhead);
font-weight: 600;
color: var(--hc-label-secondary);
}
.hc-emote-picker__icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.hc-emote-picker__name {
flex: 1;
min-width: 0;
font-size: var(--hc-text-body);
font-weight: 500;
color: var(--hc-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hc-emote-picker__empty {
padding: var(--hc-space-8) var(--hc-space-6);
text-align: center;
color: var(--hc-label-tertiary);
font-size: var(--hc-text-footnote);
}
/* Thin, subtle scrollbar for the picker list. Our overlay mounts in its own
* .halcyon host, which Discord's global scrollbar styling doesn't reach, so
* without this the list falls back to the chunky default OS scrollbar. */
.hc-emote-picker__list::-webkit-scrollbar {
width: 8px;
}
.hc-emote-picker__list::-webkit-scrollbar-track {
background: transparent;
}
.hc-emote-picker__list::-webkit-scrollbar-thumb {
background: var(--hc-fill-secondary);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.hc-emote-picker__list::-webkit-scrollbar-thumb:hover {
background: var(--hc-label-tertiary);
background-clip: padding-box;
}
/* Post-pick status view (copying / done / error), shown in place of the list
* so a clone never looks like "nothing happened" even when the toast module
* isn't present on this build. */
.hc-emote-picker__status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--hc-space-3);
padding: var(--hc-space-8) var(--hc-space-6);
}
.hc-emote-picker__status-icon {
font-size: 32px;
line-height: 1;
}
.hc-emote-picker__status[data-state="done"] .hc-emote-picker__status-icon {
color: var(--hc-green);
}
.hc-emote-picker__status[data-state="error"] .hc-emote-picker__status-icon {
color: var(--hc-red);
}
.hc-emote-picker__status-title {
font-size: var(--hc-text-headline);
line-height: var(--hc-lh-headline);
font-weight: 600;
color: var(--hc-label-primary);
}
.hc-emote-picker__status-detail {
font-size: var(--hc-text-footnote);
line-height: var(--hc-lh-footnote);
color: var(--hc-label-secondary);
max-width: 340px;
word-break: break-word;
}
/* --- Quest indicator badge ------------------------------------------------ */
/* Small count badge on the quest rail button. Positioned at top-right, styled
to match Discord's own notification badges. */
.hc-quest-btn {
position: relative;
}
.hc-quest-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ed4245;
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 18px;
text-align: center;
box-shadow: 0 0 0 3px var(--background-tertiary, #1e1f22);
}
/* --- Member count chip (member-count plugin) ------------------------------ */
/*
* Inserted into Discord's channel header toolbar or above its member list, so
* literal values and Discord's own CSS variables \u2014 the \`--hc-*\` tokens are
* scoped to \`.halcyon\` and do not reach this far into the client's tree.
* The host is inert: an empty chip (a DM, or a guild with no numbers yet)
* occupies nothing.
*/
.hc-membercount-host {
display: contents;
}
.hc-membercount {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 8px;
margin-right: 8px;
border-radius: 8px;
background: rgba(128, 132, 142, 0.12);
color: var(--interactive-normal, #b5bac1);
font-size: 13px;
font-weight: 500;
line-height: 24px;
white-space: nowrap;
cursor: default;
user-select: none;
}
.hc-membercount__icon {
flex: 0 0 auto;
opacity: 0.75;
}
.hc-membercount__part {
display: inline-flex;
align-items: center;
gap: 4px;
}
.hc-membercount__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #23a55a;
}
.hc-membercount__label {
color: var(--text-muted, #949ba4);
font-weight: 400;
}
.hc-membercount__value {
font-variant-numeric: tabular-nums;
}
.hc-membercount__sep {
color: var(--text-muted, #949ba4);
opacity: 0.6;
}
/* Above the member list, the chip sits inside the scroller (before the first
* group header), so it flows as a natural roster line rather than floating in
* empty space above everything. Layout is the same pill as the header variant;
* only the outer margin changes so it doesn't hug the aside's edge. */
.hc-membercount--list {
margin: 8px 12px 4px;
}
/* --- Reactor list card (who-reacted plugin) ------------------------------- */
/*
* Our own floating surface on document.body, hosted inside a \`.halcyon\`
* element, so this one does use the design tokens. Non-interactive by design:
* pointer-events stay off so the card can never eat the click that toggles a
* reaction.
*/
.hc-whoreacted-host {
position: fixed;
top: 0;
left: 0;
z-index: 4000;
pointer-events: none;
}
.hc-whoreacted {
min-width: 180px;
max-width: 280px;
padding: var(--hc-space-2) 0;
border-radius: var(--hc-radius-md);
background: var(--hc-bg-elevated);
box-shadow: var(--hc-elev-2);
font-size: var(--hc-text-footnote);
line-height: var(--hc-lh-footnote);
}
.hc-whoreacted__head {
display: flex;
align-items: center;
gap: var(--hc-space-2);
padding: var(--hc-space-1) var(--hc-space-3) var(--hc-space-2);
border-bottom: 1px solid var(--hc-separator);
margin-bottom: var(--hc-space-2);
}
.hc-whoreacted__emoji-img {
flex: 0 0 auto;
width: 18px;
height: 18px;
object-fit: contain;
}
.hc-whoreacted__emoji-char {
flex: 0 0 auto;
font-size: 16px;
line-height: 18px;
}
.hc-whoreacted__title {
flex: 1;
min-width: 0;
font-weight: 600;
color: var(--hc-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hc-whoreacted__count {
flex: 0 0 auto;
padding: 0 6px;
border-radius: var(--hc-radius-pill);
background: var(--hc-fill-secondary);
color: var(--hc-label-secondary);
font-size: var(--hc-text-caption2);
font-variant-numeric: tabular-nums;
}
.hc-whoreacted__hint {
padding: var(--hc-space-1) var(--hc-space-3) var(--hc-space-2);
color: var(--hc-label-secondary);
}
.hc-whoreacted__hint--error {
color: var(--hc-red);
}
.hc-whoreacted__list {
max-height: 260px;
overflow: hidden;
}
.hc-whoreacted__row {
display: flex;
align-items: center;
gap: var(--hc-space-2);
padding: 3px var(--hc-space-3);
}
.hc-whoreacted__avatar {
flex: 0 0 auto;
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
}
.hc-whoreacted__name {
flex: 1;
min-width: 0;
color: var(--hc-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hc-whoreacted__tag {
flex: 0 0 auto;
padding: 0 4px;
border-radius: var(--hc-radius-xs);
background: var(--hc-accent);
color: #fff;
font-size: var(--hc-text-caption2);
font-weight: 600;
line-height: 14px;
}
.hc-whoreacted__id {
flex: 0 0 auto;
color: var(--hc-label-tertiary);
font-family: var(--hc-font-mono);
font-size: var(--hc-text-caption2);
}
.hc-whoreacted__more {
padding: var(--hc-space-1) var(--hc-space-3) 0;
color: var(--hc-label-tertiary);
}
/* --- Platform indicators (platform-indicators plugin) --------------------- */
/*
* Inline glyphs appended inside Discord's message header and member rows, so
* literal values, no tokens. \`vertical-align: middle\` keeps them on the name's
* baseline; the status colors match Discord's own presence dots.
*/
.hc-platform-host {
display: inline;
}
.hc-platform {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 5px;
vertical-align: middle;
}
.hc-platform__item {
display: inline-flex;
align-items: center;
}
.hc-platform__item--online {
color: #23a55a;
}
.hc-platform__item--idle {
color: #f0b232;
}
.hc-platform__item--dnd {
color: #f23f43;
}
.hc-platform__item--offline,
.hc-platform__item--muted {
color: var(--text-muted, #949ba4);
}
/* --- Inline reactor avatars (who-reacted plugin) -------------------------- */
/*
* Reactor faces inside every reaction pill \u2014 the primary surface. Meant to
* blend with Discord's own count layout: same vertical center, small enough
* that a pill with 3 avatars is only a little wider than one without, and no
* background of our own so the pill's own tint (blue for reactionMe, grey
* otherwise) shows through.
*
* Attached inside \`.reactionInner__\u2026\` as its last child. Sits after the count
* with a small margin, so it reads as an appended detail rather than a
* standalone widget.
*/
.hc-inline-reactors {
display: inline-flex;
align-items: center;
margin-left: 4px;
gap: 0;
line-height: 1;
/* Not interactive: this must never eat the click that toggles your own
* reaction on the pill it's inside. */
pointer-events: none;
}
.hc-inline-reactors__avatar {
width: 14px;
height: 14px;
border-radius: 50%;
object-fit: cover;
/* A slim rim in the pill's background color visually separates overlapping
* avatars from each other without adding a foreign block color. */
border: 1.5px solid var(--background-secondary, #2b2d31);
background: var(--background-tertiary, #1e1f22);
/* Overlap each next avatar over the previous one; the first stands alone. */
margin-left: -4px;
}
.hc-inline-reactors__avatar:first-child {
margin-left: 0;
}
.hc-inline-reactors__more {
margin-left: 4px;
font-size: 11px;
font-weight: 600;
color: var(--interactive-normal, #b5bac1);
font-variant-numeric: tabular-nums;
}
/* When the pill is the "I reacted" variant Discord tints the whole pill blue,
* so switch the avatar rim to that darker inner tone (approximation \u2014 no exact
* token exists for the "reactionMe" background) so avatars don't rim in a
* conflicting color. Falls back to the default rim on builds without that
* class. */
[class*="reactionMe"] .hc-inline-reactors__avatar {
border-color: rgba(88, 101, 242, 0.35);
}
/* --- Recovered media on a deleted message (message-logger, in-chat) ------- */
/*
* Discord strips a deleted message's attachments/embeds from its render, so we
* paint the recovered thumbnails back in beneath the "\u6B64\u6D88\u606F\u5DF2\u5220\u9664" marker. Sits
* inside Discord's own message row, so literal values, no tokens.
*/
.hc-deleted-media {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.hc-deleted-media__thumb {
max-width: 240px;
max-height: 200px;
width: auto;
height: auto;
object-fit: contain;
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
}
.hc-deleted-media__file {
color: #00a8fc;
font-size: 0.8125rem;
word-break: break-all;
}
/* --- Message-log button in the channel header toolbar -------------------- */
/*
* Sits among Discord's own header icons (pin, members, \u2026), so it must read as
* one of them: same 24px hit target, muted normal color, brighter on hover.
* Decorates Discord's toolbar, so literal values + Discord CSS variables.
*/
.hc-mlog-toolbtn-host {
display: inline-flex;
align-items: center;
}
.hc-mlog-toolbtn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin: 0 8px;
padding: 0;
border: none;
background: transparent;
color: var(--interactive-normal, #b5bac1);
cursor: pointer;
transition: color 0.15s ease;
}
.hc-mlog-toolbtn:hover {
color: var(--interactive-hover, #dbdee1);
}
.hc-mlog-toolbtn:active {
color: var(--interactive-active, #fff);
}
/* --- Message-log search box ---------------------------------------------- */
/* Inside the .halcyon panel, so design tokens throughout. Mirrors the plugin
* browser's search field but on its own row above the list. */
.hc-mlog-search {
display: flex;
align-items: center;
gap: var(--hc-space-2);
height: 36px;
margin: var(--hc-space-2) 0 var(--hc-space-3);
padding: 0 var(--hc-space-3);
border-radius: var(--hc-radius-md);
background: var(--hc-fill-secondary);
color: var(--hc-label-secondary);
}
.hc-mlog-search input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
outline: none;
color: var(--hc-label-primary);
font-size: var(--hc-text-callout);
font-family: var(--hc-font);
}
.hc-mlog-search input::placeholder {
color: var(--hc-label-tertiary);
}
.hc-mlog-search__clear {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: var(--hc-radius-pill);
background: var(--hc-fill-primary);
color: var(--hc-label-secondary);
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.hc-mlog-search__clear:hover {
color: var(--hc-label-primary);
}
/* --- Thin scrollbars for Halcyon's own scroll areas ---------------------- */
/*
* The settings panel and embedded views scroll with the OS default scrollbar,
* which is a chunky light bar that reads as foreign inside the dark iOS-styled
* panel. Give those containers the same slim, self-colored bar the emote picker
* uses. Our surfaces mount in their own .halcyon host, outside Discord's global
* scrollbar styling, so these rules are needed here.
*/
.hc-panel__scroll,
.hc-embed,
.hc-msglist {
scrollbar-width: thin;
scrollbar-color: var(--hc-fill-primary) transparent;
}
.hc-panel__scroll::-webkit-scrollbar,
.hc-embed::-webkit-scrollbar,
.hc-msglist::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.hc-panel__scroll::-webkit-scrollbar-track,
.hc-embed::-webkit-scrollbar-track,
.hc-msglist::-webkit-scrollbar-track {
background: transparent;
}
.hc-panel__scroll::-webkit-scrollbar-thumb,
.hc-embed::-webkit-scrollbar-thumb,
.hc-msglist::-webkit-scrollbar-thumb {
background: var(--hc-fill-secondary);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.hc-panel__scroll::-webkit-scrollbar-thumb:hover,
.hc-embed::-webkit-scrollbar-thumb:hover,
.hc-msglist::-webkit-scrollbar-thumb:hover {
background: var(--hc-label-tertiary);
background-clip: padding-box;
}
`;
// src/ui/inject-styles.ts
var STYLE_ID = "halcyon-styles";
var mounted = false;
function injectStyles() {
if (mounted) return;
const existing = document.getElementById(STYLE_ID);
const style = existing instanceof HTMLStyleElement ? existing : document.createElement("style");
style.id = STYLE_ID;
style.textContent = `${tokens_default}
${components_default}`;
if (!existing) {
document.head.appendChild(style);
}
mounted = true;
}
// src/icons/index.tsx
function Glyph({ size = 20, className, filled, children, ...rest }) {
const label = rest["aria-label"];
if (typeof size !== "number" || !Number.isFinite(size)) size = 20;
return /* @__PURE__ */ React.createElement(
"svg",
{
className,
width: size,
height: size,
viewBox: "0 0 24 24",
fill: filled ? "currentColor" : "none",
stroke: filled ? "none" : "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round",
strokeLinejoin: "round",
role: label ? "img" : void 0,
"aria-label": label,
"aria-hidden": label ? void 0 : true
},
children
);
}
function HalcyonMark(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("rect", { x: "3.25", y: "3.25", width: "17.5", height: "17.5", rx: "5" }), /* @__PURE__ */ React.createElement("path", { d: "M6.5 13.2c1.4-2.5 2.9-2.5 4.3 0s2.9 2.5 4.3 0 2.9-2.5 2.9-2.5" }));
}
function ChevronRightIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M9 6l6 6-6 6" }));
}
function ClockIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("circle", { cx: "12", cy: "12", r: "8.25" }), /* @__PURE__ */ React.createElement("path", { d: "M12 7.5V12l3 2" }));
}
function TrashIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M4.5 7h15" }), /* @__PURE__ */ React.createElement("path", { d: "M9.25 7V5.5A1.5 1.5 0 0110.75 4h2.5A1.5 1.5 0 0114.75 5.5V7" }), /* @__PURE__ */ React.createElement("path", { d: "M6.5 7l.85 11.1A2 2 0 009.34 20h5.32a2 2 0 001.99-1.9L17.5 7" }), /* @__PURE__ */ React.createElement("path", { d: "M10 11v5.5M14 11v5.5" }));
}
function PencilIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M13.5 6.5l4 4" }), /* @__PURE__ */ React.createElement("path", { d: "M4.5 19.5l1-4L15.5 5.5a2 2 0 013 3L8.5 18.5l-4 1z" }));
}
function ShieldIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M12 3.5l7 2.6v5c0 4.4-3 7.3-7 8.9-4-1.6-7-4.5-7-8.9v-5l7-2.6z" }), /* @__PURE__ */ React.createElement("path", { d: "M9 12l2 2 4-4" }));
}
function MessageIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M5 5.5h14a1.5 1.5 0 011.5 1.5v8a1.5 1.5 0 01-1.5 1.5H9.5L5.5 20v-3H5A1.5 1.5 0 013.5 15.5V7A1.5 1.5 0 015 5.5z" }));
}
function SearchIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("circle", { cx: "11", cy: "11", r: "6.25" }), /* @__PURE__ */ React.createElement("path", { d: "M20 20l-3.8-3.8" }));
}
function XmarkIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M6.5 6.5l11 11M17.5 6.5l-11 11" }));
}
function MessageCheckIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M5 5.5h14a1.5 1.5 0 011.5 1.5v8a1.5 1.5 0 01-1.5 1.5H9.5L5.5 20v-3H5A1.5 1.5 0 013.5 15.5V7A1.5 1.5 0 015 5.5z" }), /* @__PURE__ */ React.createElement("path", { d: "M8.5 11l2.25 2.25L15.5 8.5" }));
}
function SlidersIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M4.5 8h9M17 8h2.5M4.5 16h2.5M10.5 16h9" }), /* @__PURE__ */ React.createElement("circle", { cx: "15", cy: "8", r: "2.25" }), /* @__PURE__ */ React.createElement("circle", { cx: "9", cy: "16", r: "2.25" }));
}
function SpeakerIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M4.5 9.5v5H7l4.5 3.5V6L7 9.5H4.5z" }), /* @__PURE__ */ React.createElement("path", { d: "M15 9a4 4 0 010 6" }), /* @__PURE__ */ React.createElement("path", { d: "M17.5 6.5a7.5 7.5 0 010 11" }));
}
function AppearanceIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("circle", { cx: "12", cy: "12", r: "8.25" }), /* @__PURE__ */ React.createElement("path", { d: "M12 3.75a8.25 8.25 0 010 16.5z", fill: "currentColor", stroke: "none" }));
}
function CodeIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M8.5 8L4.5 12l4 4" }), /* @__PURE__ */ React.createElement("path", { d: "M15.5 8l4 4-4 4" }), /* @__PURE__ */ React.createElement("path", { d: "M13.5 5.5l-3 13" }));
}
function EllipsisIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props, filled: true }, /* @__PURE__ */ React.createElement("circle", { cx: "5.5", cy: "12", r: "1.6" }), /* @__PURE__ */ React.createElement("circle", { cx: "12", cy: "12", r: "1.6" }), /* @__PURE__ */ React.createElement("circle", { cx: "18.5", cy: "12", r: "1.6" }));
}
function DownloadIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M12 4v10" }), /* @__PURE__ */ React.createElement("path", { d: "M8 10.5l4 4 4-4" }), /* @__PURE__ */ React.createElement("path", { d: "M5 19.5h14" }));
}
function PlusIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M12 5v14M5 12h14" }));
}
function InfoIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("circle", { cx: "12", cy: "12", r: "8.25" }), /* @__PURE__ */ React.createElement("path", { d: "M12 11v5" }), /* @__PURE__ */ React.createElement("path", { d: "M12 7.75h.01" }));
}
function WarningIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M12 4.5L3.5 19h17L12 4.5z" }), /* @__PURE__ */ React.createElement("path", { d: "M12 10v4" }), /* @__PURE__ */ React.createElement("path", { d: "M12 16.75h.01" }));
}
function ListIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M8.5 7h11M8.5 12h11M8.5 17h11" }), /* @__PURE__ */ React.createElement("path", { d: "M4.5 7h.01M4.5 12h.01M4.5 17h.01" }));
}
function MinusIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M5 12h14" }));
}
function RefreshIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M19 8.5a7.5 7.5 0 10.9 6" }), /* @__PURE__ */ React.createElement("path", { d: "M19 4v4.5h-4.5" }));
}
function ChevronLeftIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M15 6l-6 6 6 6" }));
}
function ServerIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("rect", { x: "4", y: "4", width: "16", height: "6", rx: "2" }), /* @__PURE__ */ React.createElement("rect", { x: "4", y: "14", width: "16", height: "6", rx: "2" }), /* @__PURE__ */ React.createElement("path", { d: "M8 7h.01M8 17h.01" }));
}
function BroadcastIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("circle", { cx: "12", cy: "12", r: "2" }), /* @__PURE__ */ React.createElement("path", { d: "M8.5 8.5a5 5 0 000 7M15.5 8.5a5 5 0 010 7" }), /* @__PURE__ */ React.createElement("path", { d: "M6 6a9 9 0 000 12M18 6a9 9 0 010 12" }));
}
function QuestIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props, filled: true }, /* @__PURE__ */ React.createElement("path", { d: "M7.5 21.7a8.95 8.95 0 0 1 9 0 1 1 0 0 0 1-1.73c-.6-.35-1.24-.64-1.9-.87.54-.3 1.05-.65 1.52-1.07a3.98 3.98 0 0 0 5.49-1.8.77.77 0 0 0-.24-.95 3.98 3.98 0 0 0-2.02-.76A4 4 0 0 0 23 10.47a.76.76 0 0 0-.71-.71 4.06 4.06 0 0 0-1.6.22 3.99 3.99 0 0 0 .54-5.35.77.77 0 0 0-.95-.24c-.75.36-1.37.95-1.77 1.67V6a4 4 0 0 0-4.9-3.9.77.77 0 0 0-.6.72 4 4 0 0 0 3.7 4.17c.89 1.3 1.3 2.95 1.3 4.51 0 3.66-2.75 6.5-6 6.5s-6-2.84-6-6.5c0-1.56.41-3.21 1.3-4.51A4 4 0 0 0 11 2.82a.77.77 0 0 0-.6-.72 4.01 4.01 0 0 0-4.9 3.96A4.02 4.02 0 0 0 3.73 4.4a.77.77 0 0 0-.95.24 3.98 3.98 0 0 0 .55 5.35 4 4 0 0 0-1.6-.22.76.76 0 0 0-.72.71l-.01.28a4 4 0 0 0 2.65 3.77c-.75.06-1.45.33-2.02.76-.3.22-.4.62-.24.95a4 4 0 0 0 5.49 1.8c.47.42.98.78 1.53 1.07-.67.23-1.3.52-1.91.87a1 1 0 1 0 1 1.73Z" }));
}
function PeopleIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("circle", { cx: "9", cy: "8.25", r: "3.25" }), /* @__PURE__ */ React.createElement("path", { d: "M3.5 19.5c0-2.9 2.46-5.25 5.5-5.25s5.5 2.35 5.5 5.25" }), /* @__PURE__ */ React.createElement("path", { d: "M16 5.4a3.25 3.25 0 010 6.2" }), /* @__PURE__ */ React.createElement("path", { d: "M17.2 14.6c2.03.6 3.3 2.4 3.3 4.9" }));
}
function DesktopIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("rect", { x: "3", y: "4.5", width: "18", height: "11.5", rx: "2" }), /* @__PURE__ */ React.createElement("path", { d: "M9 19.5h6M12 16v3.5" }));
}
function MobileIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("rect", { x: "7", y: "2.75", width: "10", height: "18.5", rx: "2.5" }), /* @__PURE__ */ React.createElement("path", { d: "M10.75 18.25h2.5" }));
}
function GlobeIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("circle", { cx: "12", cy: "12", r: "8.25" }), /* @__PURE__ */ React.createElement("path", { d: "M3.75 12h16.5" }), /* @__PURE__ */ React.createElement("path", { d: "M12 3.75c2.2 2.3 3.3 5.05 3.3 8.25S14.2 17.95 12 20.25c-2.2-2.3-3.3-5.05-3.3-8.25S9.8 6.05 12 3.75z" }));
}
function GamepadIcon(props) {
return /* @__PURE__ */ React.createElement(Glyph, { ...props }, /* @__PURE__ */ React.createElement("path", { d: "M7.5 7.5h9a5 5 0 014.9 6l-.5 2.6A2.5 2.5 0 0118.45 18c-.9 0-1.73-.48-2.17-1.26L15.5 15.5h-7l-.78 1.24A2.5 2.5 0 015.55 18a2.5 2.5 0 01-2.45-1.9l-.5-2.6a5 5 0 014.9-6z" }), /* @__PURE__ */ React.createElement("path", { d: "M8.25 10.5v2.25M7.12 11.6h2.26" }), /* @__PURE__ */ React.createElement("path", { d: "M15.25 11h.01M17 12.75h.01" }));
}
// src/ui/components/Toggle.tsx
function Toggle({ checked, onChange, disabled, ...rest }) {
return /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
role: "switch",
"aria-checked": checked,
"aria-label": rest["aria-label"],
className: "hc-toggle",
"data-on": checked,
disabled,
onClick: () => {
if (!disabled) onChange(!checked);
}
},
/* @__PURE__ */ React.createElement("span", { className: "hc-toggle__knob" })
);
}
// src/ui/components/ListRow.tsx
function ListRow({
icon,
iconBackground,
title,
subtitle,
accessory,
onClick,
showChevron
}) {
const interactive = typeof onClick === "function";
return /* @__PURE__ */ React.createElement(
"div",
{
className: interactive ? "hc-row hc-row--button" : "hc-row",
onClick,
role: interactive ? "button" : void 0,
tabIndex: interactive ? 0 : void 0,
onKeyDown: interactive ? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
} : void 0
},
icon && /* @__PURE__ */ React.createElement(
"div",
{
className: "hc-row__icon",
style: iconBackground ? { background: iconBackground } : void 0
},
icon
),
/* @__PURE__ */ React.createElement("div", { className: "hc-row__text" }, /* @__PURE__ */ React.createElement("div", { className: "hc-row__title" }, title), subtitle != null && subtitle !== false && /* @__PURE__ */ React.createElement("div", { className: "hc-row__subtitle" }, subtitle)),
accessory != null && accessory !== false && /* @__PURE__ */ React.createElement("div", { className: "hc-row__accessory" }, accessory),
showChevron && /* @__PURE__ */ React.createElement(ChevronRightIcon, { size: 20, className: "hc-row__chevron" })
);
}
// src/ui/components/Badge.tsx
function Badge({ tone = "neutral", children }) {
return /* @__PURE__ */ React.createElement("span", { className: "hc-badge", "data-tone": tone }, children);
}
// src/ui/components/EmptyState.tsx
function EmptyState({ icon, title, subtitle, action }) {
return /* @__PURE__ */ React.createElement("div", { className: "hc-empty" }, icon, /* @__PURE__ */ React.createElement("div", { className: "hc-empty__title" }, title), subtitle && /* @__PURE__ */ React.createElement("div", { className: "hc-empty__subtitle" }, subtitle), action && /* @__PURE__ */ React.createElement("div", { style: { marginTop: "var(--hc-space-5)" } }, action));
}
// src/ui/components/NumberStepper.tsx
function clamp(value, min, max) {
if (min != null && value < min) return min;
if (max != null && value > max) return max;
return value;
}
function NumberStepper({ value, onChange, min, max, step = 1 }) {
const atMin = min != null && value <= min;
const atMax = max != null && value >= max;
return /* @__PURE__ */ React.createElement("div", { className: "hc-stepper" }, /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-stepper__btn",
onClick: () => onChange(clamp(value - step, min, max)),
disabled: atMin,
"aria-label": "\u51CF\u5C11"
},
/* @__PURE__ */ React.createElement(MinusIcon, { size: 16 })
), /* @__PURE__ */ React.createElement("span", { className: "hc-stepper__value" }, value), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-stepper__btn",
onClick: () => onChange(clamp(value + step, min, max)),
disabled: atMax,
"aria-label": "\u589E\u52A0"
},
/* @__PURE__ */ React.createElement(PlusIcon, { size: 16 })
));
}
// src/ui/components/TextInput.tsx
function TextInput({ value, onChange, className, ...rest }) {
return /* @__PURE__ */ React.createElement(
"input",
{
className: className ? `hc-input ${className}` : "hc-input",
value,
onChange: (e) => onChange(e.currentTarget.value),
...rest
}
);
}
// src/ui/components/Select.tsx
function Select({ value, options, onChange, ...rest }) {
const [open, setOpen] = useState(false);
const [active2, setActive] = useState(-1);
const rootRef = useRef(null);
const menuRef = useRef(null);
const [menuPos, setMenuPos] = useState(null);
const current = options.find((o) => o.value === value);
useEffect(() => {
if (!open) return;
const onPress = (e) => {
const t = e.target;
if (rootRef.current?.contains(t)) return;
if (menuRef.current?.contains(t)) return;
setOpen(false);
};
document.addEventListener("pointerdown", onPress, true);
return () => document.removeEventListener("pointerdown", onPress, true);
}, [open]);
useEffect(() => {
if (!open) return;
const onMove = (e) => {
if (menuRef.current && e.target instanceof Node && menuRef.current.contains(e.target)) return;
setOpen(false);
};
window.addEventListener("scroll", onMove, true);
window.addEventListener("resize", onMove);
return () => {
window.removeEventListener("scroll", onMove, true);
window.removeEventListener("resize", onMove);
};
}, [open]);
const openMenu = () => {
const rect = rootRef.current?.getBoundingClientRect();
if (rect) {
const estimated = Math.min(280, options.length * 36 + 10);
const below = rect.bottom + 6;
const top = below + estimated > window.innerHeight - 8 ? Math.max(8, rect.top - 6 - estimated) : below;
setMenuPos({
top,
right: Math.max(8, window.innerWidth - rect.right),
width: rect.width
});
}
setActive(Math.max(0, options.findIndex((o) => o.value === value)));
setOpen(true);
};
const pick = (next) => {
setOpen(false);
if (next !== value) onChange(next);
};
const onKeyDown3 = (e) => {
if (!open) {
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
e.preventDefault();
openMenu();
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => Math.min(options.length - 1, i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => Math.max(0, i - 1));
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (active2 >= 0 && active2 < options.length) pick(options[active2].value);
} else if (e.key === "Tab") {
setOpen(false);
}
};
return /* @__PURE__ */ React.createElement("div", { className: "hc-select", ref: rootRef, onKeyDown: onKeyDown3 }, /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-select__button",
"aria-haspopup": "listbox",
"aria-expanded": open,
"aria-label": rest["aria-label"],
onClick: () => open ? setOpen(false) : openMenu()
},
/* @__PURE__ */ React.createElement("span", { className: "hc-select__value" }, current?.label ?? value),
/* @__PURE__ */ React.createElement(
"svg",
{
className: "hc-select__chevron",
width: "12",
height: "12",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2.5,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
"data-open": open
},
/* @__PURE__ */ React.createElement("path", { d: "M6 9l6 6 6-6" })
)
), open && menuPos && ReactDOM.createPortal(
/* @__PURE__ */ React.createElement(
"div",
{
className: "halcyon",
ref: menuRef,
style: { position: "fixed", top: menuPos.top, right: menuPos.right, zIndex: 1e4 },
onKeyDown: onKeyDown3
},
/* @__PURE__ */ React.createElement("div", { className: "hc-select__menu", role: "listbox", style: { minWidth: menuPos.width } }, options.map((opt, index) => /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
key: opt.value,
role: "option",
"aria-selected": opt.value === value,
className: "hc-select__option",
"data-active": index === active2,
"data-selected": opt.value === value,
onPointerEnter: () => setActive(index),
onClick: () => pick(opt.value)
},
/* @__PURE__ */ React.createElement("span", { className: "hc-select__optlabel" }, opt.label),
opt.value === value && /* @__PURE__ */ React.createElement(
"svg",
{
className: "hc-select__check",
width: "14",
height: "14",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2.5,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true
},
/* @__PURE__ */ React.createElement("path", { d: "M5 12.5l4.5 4.5L19 7" })
)
)))
),
document.body
));
}
// src/ui/components/StringListEditor.tsx
function StringListEditor({ value, onChange, itemPlaceholder }) {
const [draft, setDraft] = useState("");
const commit = () => {
const next = draft.trim();
if (!next || value.includes(next)) {
setDraft("");
return;
}
onChange([...value, next]);
setDraft("");
};
const removeAt = (index) => {
onChange(value.filter((_, i) => i !== index));
};
return /* @__PURE__ */ React.createElement("div", { className: "hc-strlist" }, value.map((item, index) => /* @__PURE__ */ React.createElement("div", { className: "hc-strlist__item", key: item }, /* @__PURE__ */ React.createElement(TextInput, { value: item, onChange: () => void 0, readOnly: true }), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-iconbtn hc-iconbtn--danger",
onClick: () => removeAt(index),
"aria-label": "\u79FB\u9664"
},
/* @__PURE__ */ React.createElement(TrashIcon, { size: 18 })
))), /* @__PURE__ */ React.createElement("div", { className: "hc-strlist__add" }, /* @__PURE__ */ React.createElement(
TextInput,
{
value: draft,
onChange: setDraft,
placeholder: itemPlaceholder ?? "\u6DFB\u52A0\u4E00\u9879",
onKeyDown: (e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
}
}
}
), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-iconbtn",
onClick: commit,
"aria-label": "\u6DFB\u52A0",
disabled: !draft.trim()
},
/* @__PURE__ */ React.createElement(PlusIcon, { size: 18 })
)));
}
// src/ui/components/Button.tsx
function Button({
variant = "secondary",
size = "md",
icon,
className,
children,
type = "button",
...rest
}) {
const classes = ["hc-btn", `hc-btn--${variant}`];
if (size !== "md") classes.push(`hc-btn--${size}`);
if (className) classes.push(className);
return /* @__PURE__ */ React.createElement("button", { type, className: classes.join(" "), ...rest }, icon, children != null && children !== false && /* @__PURE__ */ React.createElement("span", null, children));
}
// src/ui/settings/hooks.ts
function useRuntimeList() {
const [list, setList] = useState(() => runtime.list());
useEffect(() => {
const refresh = () => setList(runtime.list());
refresh();
return runtime.onChange(refresh);
}, []);
return list;
}
function useSettingsSnapshot(settings11) {
const [, bump] = useState(0);
useEffect(() => {
const unsubscribes3 = Object.keys(settings11.schema).map(
(key) => settings11.subscribe(key, () => bump((n) => n + 1))
);
return () => {
for (const off of unsubscribes3) off();
};
}, [settings11]);
return settings11.store;
}
// src/ui/settings/SettingsForm.tsx
function clone(value) {
if (value === null || typeof value !== "object") return value;
return JSON.parse(JSON.stringify(value));
}
function equal(a, b) {
if (a === b) return true;
try {
return JSON.stringify(a) === JSON.stringify(b);
} catch {
return false;
}
}
function SettingsForm({ settings: settings11 }) {
const store = useSettingsSnapshot(settings11);
const keys = useMemo(
() => Object.keys(settings11.schema).filter((key) => !settings11.schema[key].hidden),
[settings11]
);
const [draft, setDraft] = useState(() => seed(store, keys));
useEffect(() => {
setDraft(seed(store, keys));
}, [settings11]);
if (keys.length === 0) return null;
const dirty = keys.filter((key) => !equal(draft[key], store[key]));
const save = () => {
for (const key of dirty) store[key] = clone(draft[key]);
};
const discard = () => setDraft(seed(store, keys));
const sections = [];
for (const key of keys) {
const title = settings11.schema[key].group ?? "\u8BBE\u7F6E";
const last = sections[sections.length - 1];
if (last && last.title === title) last.keys.push(key);
else sections.push({ title, keys: [key] });
}
return /* @__PURE__ */ React.createElement(React.Fragment, null, sections.map((section, index) => /* @__PURE__ */ React.createElement("div", { className: "hc-section", key: `${section.title}-${index}` }, /* @__PURE__ */ React.createElement("div", { className: "hc-section__title" }, section.title), /* @__PURE__ */ React.createElement("div", { className: "hc-section__body" }, section.keys.map((key) => /* @__PURE__ */ React.createElement(
SettingField,
{
key,
def: settings11.schema[key],
value: draft[key],
onChange: (next) => setDraft((prev) => ({ ...prev, [key]: next }))
}
))))), dirty.length > 0 && /* @__PURE__ */ React.createElement("div", { className: "hc-savebar" }, /* @__PURE__ */ React.createElement("span", { className: "hc-savebar__label" }, "\u6709 ", dirty.length, " \u9879\u672A\u4FDD\u5B58\u7684\u4FEE\u6539"), /* @__PURE__ */ React.createElement("div", { className: "hc-savebar__actions" }, /* @__PURE__ */ React.createElement(Button, { size: "sm", variant: "plain", onClick: discard }, "\u653E\u5F03"), /* @__PURE__ */ React.createElement(Button, { size: "sm", variant: "primary", onClick: save }, "\u4FDD\u5B58"))));
}
function seed(store, keys) {
const out = {};
for (const key of keys) out[key] = clone(store[key]);
return out;
}
function SettingField({ def, value, onChange }) {
const label = /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, def.label), def.description && /* @__PURE__ */ React.createElement("div", { className: "hc-cell__desc" }, def.description));
switch (def.type) {
case "boolean":
return /* @__PURE__ */ React.createElement("div", { className: "hc-cell hc-cell--row" }, label, /* @__PURE__ */ React.createElement(
Toggle,
{
checked: value === true,
onChange: (next) => onChange(next),
disabled: def.disabled?.(),
"aria-label": def.label
}
));
case "number":
return /* @__PURE__ */ React.createElement("div", { className: "hc-cell hc-cell--row" }, label, /* @__PURE__ */ React.createElement(
NumberStepper,
{
value: typeof value === "number" ? value : def.default,
onChange: (next) => onChange(next),
min: def.min,
max: def.max,
step: def.step
}
));
case "select":
return /* @__PURE__ */ React.createElement("div", { className: "hc-cell hc-cell--row" }, label, /* @__PURE__ */ React.createElement(
Select,
{
value: typeof value === "string" ? value : def.default,
onChange: (next) => onChange(next),
options: def.options
}
));
case "string":
return /* @__PURE__ */ React.createElement("div", { className: "hc-cell" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell--row" }, label), /* @__PURE__ */ React.createElement("div", { className: "hc-cell__control" }, /* @__PURE__ */ React.createElement(
TextInput,
{
value: typeof value === "string" ? value : "",
onChange: (next) => onChange(next),
placeholder: def.placeholder,
maxLength: def.maxLength
}
)));
case "string-list":
return /* @__PURE__ */ React.createElement("div", { className: "hc-cell" }, label, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__control" }, /* @__PURE__ */ React.createElement(
StringListEditor,
{
value: Array.isArray(value) ? value : [],
onChange: (next) => onChange(next),
itemPlaceholder: def.itemPlaceholder
}
)));
case "custom": {
const Custom = def.component;
return /* @__PURE__ */ React.createElement("div", { className: "hc-cell" }, label, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__control" }, /* @__PURE__ */ React.createElement(Custom, { value, onChange })));
}
default:
return null;
}
}
// src/ui/settings/categories.ts
var CATEGORIES = {
utility: { label: "\u5B9E\u7528\u5DE5\u5177", color: "var(--hc-accent)", Icon: SlidersIcon },
chat: { label: "\u804A\u5929", color: "var(--hc-green)", Icon: MessageIcon },
voice: { label: "\u8BED\u97F3", color: "var(--hc-indigo)", Icon: SpeakerIcon },
appearance: { label: "\u5916\u89C2", color: "var(--hc-pink)", Icon: AppearanceIcon },
privacy: { label: "\u9690\u79C1", color: "var(--hc-teal)", Icon: ShieldIcon },
developer: { label: "\u5F00\u53D1\u8005", color: "var(--hc-orange)", Icon: CodeIcon },
misc: { label: "\u5176\u4ED6", color: "var(--hc-fill-primary)", Icon: EllipsisIcon }
};
var CATEGORY_ORDER = [
"utility",
"chat",
"voice",
"appearance",
"privacy",
"developer",
"misc"
];
// src/ui/settings/PluginsView.tsx
function PluginsView({
initialSelectedId
} = {}) {
const plugins2 = useRuntimeList().filter((p) => !p.hidden);
const [selectedId, setSelectedId] = useState(initialSelectedId ?? null);
const [query, setQuery] = useState("");
const selected = selectedId ? plugins2.find((p) => p.id === selectedId) : void 0;
if (selected) {
return /* @__PURE__ */ React.createElement(PluginDetail, { view: selected, onBack: () => setSelectedId(null) });
}
const needle = query.trim().toLowerCase();
const filtered = needle ? plugins2.filter(
(p) => p.name.toLowerCase().includes(needle) || p.description.toLowerCase().includes(needle)
) : plugins2;
return /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "hc-toolbar" }, /* @__PURE__ */ React.createElement("div", { className: "hc-search" }, /* @__PURE__ */ React.createElement(SearchIcon, { size: 20 }), /* @__PURE__ */ React.createElement(
"input",
{
value: query,
onChange: (event) => setQuery(event.currentTarget.value),
placeholder: "\u641C\u7D22\u63D2\u4EF6",
"aria-label": "\u641C\u7D22\u63D2\u4EF6"
}
))), filtered.length === 0 ? /* @__PURE__ */ React.createElement(
EmptyState,
{
icon: /* @__PURE__ */ React.createElement(SearchIcon, { size: 48 }),
title: "\u6CA1\u6709\u5339\u914D\u7684\u63D2\u4EF6",
subtitle: "\u6362\u4E2A\u5173\u952E\u8BCD\u518D\u8BD5\u8BD5\u3002"
}
) : CATEGORY_ORDER.map((category) => {
const inCategory = filtered.filter((p) => p.category === category);
if (inCategory.length === 0) return null;
const meta = CATEGORIES[category];
return /* @__PURE__ */ React.createElement("div", { className: "hc-section", key: category }, /* @__PURE__ */ React.createElement("div", { className: "hc-section__title" }, meta.label), /* @__PURE__ */ React.createElement("div", { className: "hc-section__body" }, inCategory.map((view) => /* @__PURE__ */ React.createElement(
PluginRow,
{
key: view.id,
view,
onOpen: () => setSelectedId(view.id)
}
))));
}));
}
function PluginRow({ view, onOpen }) {
const meta = CATEGORIES[view.category];
const Icon = meta.Icon;
const openable = view.hasSettings || view.hasPage;
return /* @__PURE__ */ React.createElement(
ListRow,
{
icon: /* @__PURE__ */ React.createElement(Icon, { size: 18 }),
iconBackground: meta.color,
title: view.name,
subtitle: view.description,
onClick: openable ? onOpen : void 0,
showChevron: openable,
accessory: /* @__PURE__ */ React.createElement(React.Fragment, null, view.needsRestart && /* @__PURE__ */ React.createElement(Badge, { tone: "orange" }, /* @__PURE__ */ React.createElement(RefreshIcon, { size: 12 }), " \u5F85\u91CD\u542F"), view.state === "errored" && /* @__PURE__ */ React.createElement(Badge, { tone: "red" }, /* @__PURE__ */ React.createElement(WarningIcon, { size: 12 }), " \u51FA\u9519"), /* @__PURE__ */ React.createElement(
"span",
{
onClick: (event) => event.stopPropagation(),
onKeyDown: (event) => event.stopPropagation()
},
/* @__PURE__ */ React.createElement(
Toggle,
{
checked: view.enabled,
disabled: view.required,
onChange: () => runtime.toggle(view.id),
"aria-label": `\u542F\u7528 ${view.name}`
}
)
))
}
);
}
function PluginDetail({ view, onBack }) {
const plugin = runtime.getPlugin(view.id);
const meta = CATEGORIES[view.category];
const Icon = meta.Icon;
const hasVisibleSettings = Boolean(
plugin?.settings && Object.values(plugin.settings.schema).some((def) => !def.hidden)
);
const hasBoth = Boolean(plugin?.page) && hasVisibleSettings;
const [section, setSection] = useState("page");
return /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("button", { type: "button", className: "hc-back", onClick: onBack }, /* @__PURE__ */ React.createElement(ChevronLeftIcon, { size: 20 }), "\u63D2\u4EF6"), /* @__PURE__ */ React.createElement("div", { className: "hc-detail-head" }, /* @__PURE__ */ React.createElement("div", { className: "hc-detail-head__icon", style: { background: meta.color } }, /* @__PURE__ */ React.createElement(Icon, { size: 26 })), /* @__PURE__ */ React.createElement("div", { className: "hc-detail-head__text" }, /* @__PURE__ */ React.createElement("div", { className: "hc-detail-head__name" }, view.name), /* @__PURE__ */ React.createElement("div", { className: "hc-detail-head__desc" }, view.description), /* @__PURE__ */ React.createElement("div", { className: "hc-detail-head__meta" }, view.authors.map((a) => a.name).join("\u3001"))), /* @__PURE__ */ React.createElement(
"span",
{
onClick: (event) => event.stopPropagation(),
onKeyDown: (event) => event.stopPropagation()
},
/* @__PURE__ */ React.createElement(
Toggle,
{
checked: view.enabled,
disabled: view.required,
onChange: () => runtime.toggle(view.id),
"aria-label": `\u542F\u7528 ${view.name}`
}
)
)), view.needsRestart && /* @__PURE__ */ React.createElement("div", { className: "hc-inline-note" }, /* @__PURE__ */ React.createElement(RefreshIcon, { size: 18 }), /* @__PURE__ */ React.createElement("span", null, "\u8FD9\u4E2A\u63D2\u4EF6\u5305\u542B\u52A0\u8F7D\u671F\u8865\u4E01\uFF0C\u9700\u8981\u91CD\u542F Discord \u624D\u80FD\u5B8C\u5168\u751F\u6548\u3002")), view.state === "errored" && /* @__PURE__ */ React.createElement("div", { className: "hc-inline-note hc-inline-note--danger" }, /* @__PURE__ */ React.createElement(WarningIcon, { size: 18 }), /* @__PURE__ */ React.createElement("span", null, "\u63D2\u4EF6\u542F\u52A8\u65F6\u629B\u51FA\u5F02\u5E38\uFF0C\u5DF2\u88AB\u81EA\u52A8\u505C\u7528\uFF0C\u8BE6\u60C5\u89C1\u65E5\u5FD7\u3002")), hasBoth && /* @__PURE__ */ React.createElement("div", { className: "hc-segment" }, /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-segment__item",
"data-active": section === "page",
onClick: () => setSection("page")
},
plugin.page.title || "\u8BB0\u5F55"
), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-segment__item",
"data-active": section === "settings",
onClick: () => setSection("settings")
},
"\u8BBE\u7F6E"
)), plugin?.page && (!hasBoth || section === "page") ? /* @__PURE__ */ React.createElement(plugin.page.component, null) : plugin?.settings ? /* @__PURE__ */ React.createElement(SettingsForm, { settings: plugin.settings }) : /* @__PURE__ */ React.createElement(EmptyState, { title: "\u6CA1\u6709\u53EF\u914D\u7F6E\u9879", subtitle: "\u8FD9\u4E2A\u63D2\u4EF6\u5F00\u7BB1\u5373\u7528\uFF0C\u65E0\u9700\u8BBE\u7F6E\u3002" }));
}
// src/ui/settings/LogsView.tsx
var MAX_VISIBLE = 500;
var PAGE_SIZE = 100;
function LogsView() {
const [entries, setEntries] = useState(() => getLogHistory().slice());
const [page, setPage] = useState(0);
const scrollRef = useRef(null);
useEffect(() => {
setEntries(getLogHistory().slice());
return onLog((entry) => {
setEntries((prev) => {
const next = prev.concat(entry);
return next.length > MAX_VISIBLE ? next.slice(next.length - MAX_VISIBLE) : next;
});
});
}, []);
const pageCount = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
const clamped = Math.min(page, pageCount - 1);
const end = entries.length - clamped * PAGE_SIZE;
const visible = entries.slice(Math.max(0, end - PAGE_SIZE), end);
useEffect(() => {
if (clamped !== 0) return;
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [entries, clamped]);
if (entries.length === 0) {
return /* @__PURE__ */ React.createElement(
EmptyState,
{
icon: /* @__PURE__ */ React.createElement(ListIcon, { size: 48 }),
title: "\u6682\u65E0\u65E5\u5FD7",
subtitle: "\u8FD0\u884C\u65F6\u548C\u63D2\u4EF6\u7684\u8F93\u51FA\u4F1A\u5B9E\u65F6\u51FA\u73B0\u5728\u8FD9\u91CC\u3002"
}
);
}
return /* @__PURE__ */ React.createElement("div", { className: "hc-stack" }, /* @__PURE__ */ React.createElement("div", { className: "hc-logs", ref: scrollRef }, visible.map((entry, index) => /* @__PURE__ */ React.createElement("div", { className: "hc-logline", "data-level": entry.level, key: `${entry.time}-${index}` }, /* @__PURE__ */ React.createElement("span", { className: "hc-logline__time" }, formatTime(entry.time)), /* @__PURE__ */ React.createElement("span", { className: "hc-logline__scope" }, entry.scope), /* @__PURE__ */ React.createElement("span", { className: "hc-logline__msg" }, entry.parts.map(stringify).join(" "))))), pageCount > 1 && /* @__PURE__ */ React.createElement("div", { className: "hc-pager" }, /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-tab",
disabled: clamped >= pageCount - 1,
onClick: () => setPage(Math.min(pageCount - 1, clamped + 1))
},
"\u2190 \u66F4\u65E9"
), /* @__PURE__ */ React.createElement("span", { className: "hc-pager__label" }, clamped === 0 ? "\u5B9E\u65F6" : `\u7B2C ${pageCount - clamped} / ${pageCount} \u9875`), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-tab",
disabled: clamped === 0,
onClick: () => setPage(Math.max(0, clamped - 1))
},
"\u66F4\u65B0 \u2192"
)));
}
function formatTime(time) {
const date = new Date(time);
const clock = date.toLocaleTimeString(void 0, { hour12: false });
return `${clock}.${String(date.getMilliseconds()).padStart(3, "0")}`;
}
function stringify(part) {
if (typeof part === "string") return part;
if (part instanceof Error) return part.stack ?? part.message;
try {
return JSON.stringify(part);
} catch {
return String(part);
}
}
// src/ui/components/Section.tsx
function Section({ title, note, children }) {
return /* @__PURE__ */ React.createElement("div", { className: "hc-section" }, title && /* @__PURE__ */ React.createElement("div", { className: "hc-section__title" }, title), /* @__PURE__ */ React.createElement("div", { className: "hc-section__body" }, children), note && /* @__PURE__ */ React.createElement("div", { className: "hc-section__note" }, note));
}
// src/core/update.ts
var log4 = logger("update");
var REPO = "mzrodyu/CatieDiscordTools";
var VERSION_URL = `https://raw.githubusercontent.com/${REPO}/main/package.json`;
var PROJECT_URL = `https://github.com/${REPO}`;
var cached = null;
var inflight = null;
function currentVersion() {
return true ? "0.6.1" : "dev";
}
function getCachedUpdate() {
return cached;
}
function parseVersion(v) {
return String(v).trim().replace(/^v/i, "").split(/[.+-]/).map((p) => parseInt(p, 10)).filter((n) => Number.isFinite(n));
}
function isNewer(remote, local) {
const a = parseVersion(remote);
const b = parseVersion(local);
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i++) {
const x = a[i] ?? 0;
const y = b[i] ?? 0;
if (x !== y) return x > y;
}
return false;
}
async function fetchText(url) {
const native = globalThis.HalcyonNative;
if (native && typeof native.fetchText === "function") {
try {
const text = await native.fetchText(url);
if (typeof text === "string") return text;
} catch {
}
}
try {
const res = await fetch(url, { cache: "no-store" });
if (res.ok) return await res.text();
} catch {
}
return null;
}
async function checkForUpdate(force = false) {
if (!force && cached && cached.status !== "unknown") return cached;
if (inflight) return inflight;
inflight = (async () => {
const current = currentVersion();
const raw = await fetchText(VERSION_URL);
let state;
if (raw == null) {
state = { status: "unknown", current, latest: null };
} else {
let latest = null;
try {
const parsed = JSON.parse(raw);
latest = typeof parsed?.version === "string" && parsed.version ? parsed.version : null;
} catch {
latest = null;
}
if (!latest) {
state = { status: "unknown", current, latest: null };
} else if (current === "dev") {
state = { status: "current", current, latest };
} else {
state = { status: isNewer(latest, current) ? "outdated" : "current", current, latest };
}
}
if (state.status === "outdated") {
log4.info(`update available: ${state.current} \u2192 ${state.latest}`);
} else if (state.status === "unknown") {
log4.info("could not determine the latest version (CSP or offline) \u2014 skipping notice");
} else {
log4.info(`up to date (${state.current})`);
}
cached = state;
inflight = null;
return state;
})();
return inflight;
}
// src/ui/settings/AboutView.tsx
function AboutView() {
const plugins2 = useRuntimeList().filter((p) => !p.hidden);
const enabled = plugins2.filter((p) => p.enabled).length;
const version2 = true ? "0.6.1" : "dev";
const [update, setUpdate] = React.useState(getCachedUpdate);
React.useEffect(() => {
let alive = true;
void checkForUpdate().then((state) => {
if (alive) setUpdate(state);
});
return () => {
alive = false;
};
}, []);
return /* @__PURE__ */ React.createElement("div", { className: "hc-stack" }, /* @__PURE__ */ React.createElement("div", { className: "hc-about-hero" }, /* @__PURE__ */ React.createElement(HalcyonMark, { size: 32 }), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "hc-about-hero__name" }, "Halcyon"), /* @__PURE__ */ React.createElement("div", { className: "hc-about-hero__ver" }, "\u7248\u672C ", version2, update?.status === "outdated" && "\uFF0C\u6709\u65B0\u7248\u672C\u53EF\u7528"))), update?.status === "outdated" && /* @__PURE__ */ React.createElement(Section, { title: "\u66F4\u65B0" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell hc-cell--row" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, "\u53D1\u73B0\u65B0\u7248\u672C ", update.latest)), /* @__PURE__ */ React.createElement(
Button,
{
variant: "primary",
size: "sm",
onClick: () => window.open(PROJECT_URL, "_blank", "noopener,noreferrer")
},
"\u524D\u5F80\u4E0B\u8F7D"
))), /* @__PURE__ */ React.createElement(Section, { title: "\u6982\u89C8" }, /* @__PURE__ */ React.createElement(AboutRow, { label: "\u63D2\u4EF6\u603B\u6570", value: String(plugins2.length) }), /* @__PURE__ */ React.createElement(AboutRow, { label: "\u5DF2\u542F\u7528", value: String(enabled) })), /* @__PURE__ */ React.createElement(
Section,
{
title: "\u9879\u76EE",
note: "\u4FEE\u6539 Discord \u5BA2\u6237\u7AEF\u8FDD\u53CD\u5176\u670D\u52A1\u6761\u6B3E\uFF0C\u7531\u6B64\u4EA7\u751F\u7684\u4EFB\u4F55\u540E\u679C\u7531\u4F7F\u7528\u8005\u81EA\u884C\u627F\u62C5\u3002\u672C\u9879\u76EE\u4EC5\u4F9B\u6280\u672F\u7814\u7A76\u4E0E\u4E2A\u4EBA\u4F7F\u7528\u3002"
},
/* @__PURE__ */ React.createElement(AboutRow, { label: "\u4F5C\u8005", value: "caitemm (mzrodyu)" }),
/* @__PURE__ */ React.createElement(AboutRow, { label: "\u8BB8\u53EF\u534F\u8BAE", value: "GPL-3.0-or-later" })
));
}
function AboutRow({ label, value }) {
return /* @__PURE__ */ React.createElement("div", { className: "hc-cell hc-cell--row" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, label)), /* @__PURE__ */ React.createElement("span", { className: "hc-about__value" }, value));
}
// src/ui/settings/SettingsRoot.tsx
var TABS = [
{ id: "plugins", label: "\u63D2\u4EF6", title: "\u63D2\u4EF6", Icon: SlidersIcon },
{ id: "logs", label: "\u65E5\u5FD7", title: "\u65E5\u5FD7", Icon: ListIcon },
{ id: "about", label: "\u5173\u4E8E", title: "\u5173\u4E8E Halcyon", Icon: InfoIcon }
];
function renderView(tab, initialPluginId) {
switch (tab) {
case "plugins":
return /* @__PURE__ */ React.createElement(PluginsView, { initialSelectedId: initialPluginId });
case "logs":
return /* @__PURE__ */ React.createElement(LogsView, null);
case "about":
return /* @__PURE__ */ React.createElement(AboutView, null);
}
}
function SettingsRoot({
onClose,
initial
}) {
const [tab, setTab] = useState(initial?.tab ?? "plugins");
const [initialPluginId] = useState(initial?.pluginId);
const active2 = TABS.find((t) => t.id === tab) ?? TABS[0];
return /* @__PURE__ */ React.createElement("div", { className: "halcyon hc-panel" }, /* @__PURE__ */ React.createElement("nav", { className: "hc-panel__sidebar" }, /* @__PURE__ */ React.createElement("div", { className: "hc-panel__brand" }, /* @__PURE__ */ React.createElement(HalcyonMark, { size: 24 }), /* @__PURE__ */ React.createElement("span", { className: "hc-panel__brand-name" }, "Halcyon")), TABS.map((t) => /* @__PURE__ */ React.createElement(
"button",
{
key: t.id,
type: "button",
className: "hc-navitem",
"data-active": t.id === tab,
onClick: () => setTab(t.id)
},
/* @__PURE__ */ React.createElement(t.Icon, { size: 18 }),
t.label
))), /* @__PURE__ */ React.createElement("section", { className: "hc-panel__content" }, /* @__PURE__ */ React.createElement("header", { className: "hc-panel__header" }, /* @__PURE__ */ React.createElement("span", { className: "hc-title2" }, active2.title), onClose && /* @__PURE__ */ React.createElement("button", { type: "button", className: "hc-iconbtn", onClick: onClose, "aria-label": "\u5173\u95ED" }, /* @__PURE__ */ React.createElement(XmarkIcon, { size: 20 }))), /* @__PURE__ */ React.createElement("div", { className: "hc-panel__scroll" }, renderView(tab, tab === "plugins" ? initialPluginId : void 0))));
}
function EmbeddedView({ tab }) {
return /* @__PURE__ */ React.createElement("div", { className: "halcyon hc-embed" }, renderView(tab));
}
// src/ui/settings/overlay.tsx
var log5 = logger("settings");
var host = null;
var unmount = null;
var keyHandler = null;
function openSettings(target) {
injectStyles();
if (host) return;
host = document.createElement("div");
host.className = "halcyon";
document.body.appendChild(host);
keyHandler = (event) => {
if (event.key === "Escape") closeSettings();
};
document.addEventListener("keydown", keyHandler);
try {
unmount = mountDetached(
React.createElement(Overlay, { onClose: closeSettings, target }),
host
);
} catch (err) {
log5.error("could not open settings overlay", err);
closeSettings();
}
}
function closeSettings() {
if (keyHandler) {
document.removeEventListener("keydown", keyHandler);
keyHandler = null;
}
if (unmount) {
unmount();
unmount = null;
}
if (host) {
host.remove();
host = null;
}
}
function Overlay({
onClose,
target
}) {
return /* @__PURE__ */ React.createElement(
"div",
{
className: "hc-overlay",
role: "dialog",
"aria-modal": "true",
"aria-label": "Halcyon \u8BBE\u7F6E",
onMouseDown: (event) => {
if (event.target === event.currentTarget) onClose();
}
},
/* @__PURE__ */ React.createElement(SettingsRoot, { onClose, initial: target })
);
}
// src/plugins/settings-host/index.tsx
var log6 = logger("settings-host");
function PluginsSection() {
return /* @__PURE__ */ React.createElement(EmbeddedView, { tab: "plugins" });
}
function LogsSection() {
return /* @__PURE__ */ React.createElement(EmbeddedView, { tab: "logs" });
}
function AboutSection() {
return /* @__PURE__ */ React.createElement(EmbeddedView, { tab: "about" });
}
function sidebarIcon(Icon) {
return function HalcyonSidebarIcon() {
return /* @__PURE__ */ React.createElement(Icon, { size: 20 });
};
}
var HALCYON_SECTION_KEY = "halcyon-section";
var ENTRIES = [
{ key: "halcyon-plugins", title: "\u63D2\u4EF6", Component: PluginsSection, Icon: SlidersIcon },
{ key: "halcyon-logs", title: "\u65E5\u5FD7", Component: LogsSection, Icon: ListIcon },
{ key: "halcyon-about", title: "\u5173\u4E8E", Component: AboutSection, Icon: InfoIcon }
];
var diagLogged = false;
var INJECT_SECTION = true;
var FALLBACK_LAYOUT_TYPES = {
SECTION: 1,
SIDEBAR_ITEM: 2,
PANEL: 3,
CATEGORY: 5,
CUSTOM: 20
};
var layoutTypes = null;
function getLayoutTypes() {
if (layoutTypes) return layoutTypes;
try {
const found = findByProps("SECTION", "SIDEBAR_ITEM", "PANEL", "CUSTOM");
if (found && typeof found.SECTION === "number") {
layoutTypes = {
SECTION: found.SECTION,
SIDEBAR_ITEM: found.SIDEBAR_ITEM,
PANEL: found.PANEL,
CATEGORY: typeof found.CATEGORY === "number" ? found.CATEGORY : FALLBACK_LAYOUT_TYPES.CATEGORY,
CUSTOM: found.CUSTOM
};
return layoutTypes;
}
} catch (err) {
log6.warn("could not resolve settings layout types; using fallback values", err);
}
return FALLBACK_LAYOUT_TYPES;
}
function safeChildren(node) {
try {
if (node && typeof node.buildLayout === "function") {
const kids = node.buildLayout();
if (Array.isArray(kids)) return kids;
}
} catch {
}
return [];
}
function resolveTypesFromLayout(layout) {
const types = { ...FALLBACK_LAYOUT_TYPES };
try {
const first = Array.isArray(layout) ? layout[0] : void 0;
if (first && typeof first.type === "number") types.SECTION = first.type;
for (const section of layout) {
for (const item of safeChildren(section)) {
if (typeof item?.type !== "number") continue;
types.SIDEBAR_ITEM = item.type;
for (const panel of safeChildren(item)) {
if (typeof panel?.type !== "number") continue;
types.PANEL = panel.type;
for (const category of safeChildren(panel)) {
if (typeof category?.type !== "number") continue;
types.CATEGORY = category.type;
for (const leaf of safeChildren(category)) {
if (leaf && typeof leaf.type === "number" && "Component" in leaf) {
types.CUSTOM = leaf.type;
return types;
}
}
}
}
}
}
} catch (err) {
log6.warn("could not read layout types from the live tree; using fallbacks", err);
}
return types;
}
function buildEntry(types, entry) {
const panel = {
key: `${entry.key}-panel`,
type: types.PANEL,
useTitle: () => entry.title,
buildLayout: () => [
{
key: `${entry.key}-category`,
type: types.CATEGORY,
buildLayout: () => [
{
key: `${entry.key}-custom`,
type: types.CUSTOM,
Component: entry.Component,
useSearchTerms: () => [entry.title]
}
]
}
]
};
return {
key: entry.key,
type: types.SIDEBAR_ITEM,
useTitle: () => entry.title,
icon: sidebarIcon(entry.Icon),
buildLayout: () => [panel]
};
}
function fnSources(node) {
const out = {};
if (node && typeof node === "object") {
for (const k of Object.keys(node)) {
const v = node[k];
if (typeof v === "function") out[k] = String(v).replace(/\s+/g, " ").slice(0, 400);
}
}
return out;
}
function describeNode(node, depth) {
if (!node || typeof node !== "object") return { raw: typeof node };
const info = {
key: node.key,
type: node.type,
fields: Object.keys(node)
};
if (depth > 0 && typeof node.buildLayout === "function") {
try {
const kids = node.buildLayout();
if (Array.isArray(kids)) {
info.children = kids.slice(0, 6).map((k) => describeNode(k, depth - 1));
}
} catch (err) {
info.childrenError = String(err);
}
}
return info;
}
function probeLayoutOnce(layout) {
if (diagLogged) return;
diagLogged = true;
try {
const s0 = layout[0];
const s1 = safeChildren(s0)[0];
const s2 = safeChildren(s1)[0];
const s3 = safeChildren(s2)[0];
const s4 = safeChildren(s3)[0];
const payload = {
resolvedTypesFromEnum: getLayoutTypes(),
resolvedTypesFromLive: resolveTypesFromLayout(layout),
topLevelCount: layout.length,
// The real function bodies Discord ships for one full branch: section ->
// sidebar item -> panel -> category -> leaf. This is the ground truth for
// what our own nodes must return (e.g. does useTitle yield a plain string
// or an intl message object {locale, ast}?).
sampleSources: {
section: fnSources(s0),
sidebarItem: fnSources(s1),
panel: fnSources(s2),
category: fnSources(s3),
leaf: fnSources(s4)
},
layout: layout.slice(0, 12).map((n) => describeNode(n, 2))
};
globalThis.__halcyonLayoutProbe = JSON.stringify(payload, null, 2);
log6.info(
"[embed-probe] captured Discord's settings layout shape. In the console run copy(__halcyonLayoutProbe) and paste the result back."
);
} catch (err) {
log6.warn("[embed-probe] failed to capture layout shape", err);
}
}
function buildLegacySections() {
return [
{ section: "HEADER", label: "HALCYON" },
{ section: "halcyon-plugins", label: "\u63D2\u4EF6", element: PluginsSection },
{ section: "halcyon-logs", label: "\u65E5\u5FD7", element: LogsSection },
{ section: "halcyon-about", label: "\u5173\u4E8E", element: AboutSection }
];
}
var onKeyDown = null;
var settings_host_default = definePlugin({
id: "halcyon-settings",
name: "Halcyon \u8BBE\u7F6E",
description: "Halcyon \u81EA\u8EAB\u7684\u8BBE\u7F6E\u754C\u9762\u5BBF\u4E3B\u3002",
authors: [{ name: "caitemm" }],
category: "misc",
required: true,
hidden: true,
patches: [
{
// Current builds. The sidebar renderer maps over a layout array produced
// by `<builder>.buildLayout()`; we hand that call to `buildLayout` below,
// which calls the original and splices Halcyon's SECTION in. `.map` in
// the lookahead pins us to the render call site (not the many nested
// `buildLayout()` calls the tree makes internally).
label: "user-settings-layout",
find: ".buildLayout().map",
replacement: {
match: /([A-Za-z_$][\w$]*)\.buildLayout\(\)(?=\.map)/,
replace: "$self.buildLayout($1)"
}
},
{
// Older builds. `getPredicateSections(){return <expr>}` gates the whole
// sidebar; we wrap its return value. The captured body may itself contain
// braces (arrow predicates, object literals), so it is matched as a
// balanced block and replayed inside an arrow IIFE — `this` stays lexical,
// so `this.props...` inside still resolves.
label: "user-settings-sidebar",
find: "getPredicateSections",
replacement: {
match: /getPredicateSections\(\)(\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\})/,
replace: (_full, body) => `getPredicateSections(){return $self.injectSections((()=>${body})())}`
}
}
],
/**
* Wrap the settings layout builder. Called from the patch with the object the
* renderer was about to call `.buildLayout()` on; we call it ourselves, then
* splice Halcyon's SECTION into the result.
*
* Defensive throughout: this feeds the entire settings pane, so any failure
* falls through to the untouched layout rather than blanking it. Only the
* root builder ("$Root") owns the sidebar — every panel and category runs its
* own builder through here too, and those are left exactly as they were.
*/
buildLayout(builder) {
const layout = builder.buildLayout();
try {
if (!builder || builder.key !== "$Root") return layout;
if (!Array.isArray(layout)) return layout;
probeLayoutOnce(layout);
if (!INJECT_SECTION) return layout;
if (layout.some((n) => n?.key === HALCYON_SECTION_KEY)) return layout;
const types = resolveTypesFromLayout(layout);
const section = {
key: HALCYON_SECTION_KEY,
type: types.SECTION,
useTitle: () => "HALCYON",
buildLayout: () => ENTRIES.map((entry) => buildEntry(types, entry))
};
let index = layout.findIndex((n) => n?.key === "billing_section");
if (index < 0) index = layout.findIndex((n) => n?.key === "user_section");
if (index < 0) index = Math.min(2, layout.length);
layout.splice(index, 0, section);
log6.info(`native settings embed active \u2014 section inserted at index ${index}/${layout.length}`);
return layout;
} catch (err) {
log6.error("failed to inject settings section into layout", err);
return layout;
}
},
/**
* Splice Halcyon's group into the legacy settings sidebar array and return
* it. Called from the older-build patch with that build's section list.
*
* Placement: right after the first divider, the seam between the account
* block and the rest. Defensive: a throw here would blank the whole pane, so
* any failure returns the original array untouched.
*/
injectSections(sections) {
try {
if (!Array.isArray(sections)) return sections;
if (sections.some((s) => s?.section === "halcyon-plugins")) return sections;
const mine = buildLegacySections();
const out = sections.slice();
const firstDivider = out.findIndex((s) => s && s.section === "DIVIDER");
if (firstDivider >= 0) {
out.splice(firstDivider + 1, 0, ...mine);
} else {
out.push({ section: "DIVIDER" }, ...mine);
}
if (!diagLogged) {
diagLogged = true;
log6.info(`native settings embed active (legacy) \u2014 ${sections.length} base sections`);
}
return out;
} catch (err) {
log6.error("failed to inject settings sections", err);
return sections;
}
},
start() {
injectStyles();
onKeyDown = (event) => {
const combo = (event.ctrlKey || event.metaKey) && event.shiftKey && event.code === "KeyH";
if (!combo) return;
event.preventDefault();
openSettings();
};
window.addEventListener("keydown", onKeyDown);
log6.info("settings host ready \u2014 open with Ctrl/Cmd+Shift+H");
},
stop() {
if (onKeyDown) {
window.removeEventListener("keydown", onKeyDown);
onKeyDown = null;
}
closeSettings();
}
});
// src/core/common/context-menu.ts
var log7 = logger("context-menu");
var navPatches = /* @__PURE__ */ new Map();
var lastTarget = null;
var trackingInstalled = false;
function installTargetTracking() {
if (trackingInstalled || typeof document === "undefined") return;
trackingInstalled = true;
document.addEventListener(
"contextmenu",
(e) => {
lastTarget = e.target ?? null;
},
true
);
}
function getContextMenuTarget() {
return lastTarget;
}
var menuItemComponent = null;
function getMenuItemComponent() {
return menuItemComponent;
}
function findMenuItemType(children) {
for (const child of children) {
if (child == null) continue;
if (Array.isArray(child)) {
const found = findMenuItemType(child);
if (found) return found;
}
const props = child.props;
if (child.type && props && typeof props.id === "string" && (props.action != null || props.label != null || props.render != null || props.onClick != null || props.subtext != null)) {
return child.type;
}
const sub = props?.children;
if (sub) {
const found = findMenuItemType(Array.isArray(sub) ? sub : [sub]);
if (found) return found;
}
}
return null;
}
function addContextMenuPatch(navId, callback) {
installTargetTracking();
const ids = Array.isArray(navId) ? navId : [navId];
for (const id of ids) {
let set = navPatches.get(id);
if (!set) {
set = /* @__PURE__ */ new Set();
navPatches.set(id, set);
}
set.add(callback);
}
return () => {
for (const id of ids) navPatches.get(id)?.delete(callback);
};
}
function removeContextMenuPatch(navId, callback) {
const ids = Array.isArray(navId) ? navId : [navId];
for (const id of ids) navPatches.get(id)?.delete(callback);
}
function cloneChildren(children) {
if (Array.isArray(children)) return children.slice();
return children == null ? [] : [children];
}
function usePatchContextMenu(props) {
try {
if (!props || typeof props.navId !== "string") return props;
if (!menuItemComponent && props.children != null) {
menuItemComponent = findMenuItemType(cloneChildren(props.children));
}
const set = navPatches.get(props.navId);
if (!set || set.size === 0) return props;
const next = { ...props, children: cloneChildren(props.children) };
for (const cb of set) {
try {
cb(next.children);
} catch (err) {
log7.error(`context-menu patch for "${props.navId}" threw`, err);
}
}
return next;
} catch (err) {
log7.error("failed to apply context-menu patches", err);
return props;
}
}
// src/plugins/context-menu-api/index.ts
var context_menu_api_default = definePlugin({
id: "context-menu-api",
name: "\u53F3\u952E\u83DC\u5355 API",
description: "\u4E3A\u5176\u4ED6\u63D2\u4EF6\u63D0\u4F9B\u5411 Discord \u53F3\u952E\u83DC\u5355\u6CE8\u5165\u83DC\u5355\u9879\u7684\u80FD\u529B\u3002",
authors: [{ name: "Vencord" }, { name: "caitemm" }],
category: "misc",
required: true,
hidden: true,
patches: [
{
// The central menu component. Inject our hook at the very top of the
// function body, right before it destructures navId out of its props.
label: "context-menu central handler",
find: "Menu API only allows Items",
replacement: {
match: /(?=let\{navId:)(?<=function [A-Za-z_$][\w$]*\(([A-Za-z_$][\w$]*)\).+?)/,
replace: "$1=$self._usePatchContextMenu($1);"
}
}
],
/** Called from the patched menu component with its props. */
_usePatchContextMenu(props) {
return usePatchContextMenu(props);
}
});
// src/core/patcher/index.ts
var log8 = logger("patcher");
var INSTALLED = Symbol("halcyon.patch");
function ensureInstalled(target, method) {
const current = target[method];
if (current && current[INSTALLED]) {
return current[INSTALLED];
}
if (typeof current !== "function") {
throw new TypeError(`cannot patch "${method}": not a function`);
}
const hooks = {
before: /* @__PURE__ */ new Set(),
instead: /* @__PURE__ */ new Set(),
after: /* @__PURE__ */ new Set(),
original: current
};
const wrapper = function(...args) {
const ctx = {
args,
result: void 0,
self: this,
callOriginal: () => hooks.original.apply(this, ctx.args)
};
for (const hook of hooks.before) {
try {
hook(ctx);
} catch (err) {
log8.error(`before-hook on "${method}" threw`, err);
}
}
if (hooks.instead.size) {
let outcome;
let ran = false;
for (const hook of hooks.instead) {
try {
outcome = hook(ctx);
ran = true;
} catch (err) {
log8.error(`instead-hook on "${method}" threw; falling back to original`, err);
outcome = ctx.callOriginal();
ran = true;
}
}
ctx.result = ran ? outcome : ctx.callOriginal();
} else {
try {
ctx.result = hooks.original.apply(this, ctx.args);
} catch (err) {
throw err;
}
}
for (const hook of hooks.after) {
try {
hook(ctx);
} catch (err) {
log8.error(`after-hook on "${method}" threw`, err);
}
}
return ctx.result;
};
Object.defineProperty(wrapper, "name", { value: current.name, configurable: true });
Object.defineProperty(wrapper, "length", { value: current.length, configurable: true });
wrapper.toString = () => hooks.original.toString();
wrapper[INSTALLED] = hooks;
Object.assign(wrapper, current);
target[method] = wrapper;
return hooks;
}
function maybeRestore(target, method, hooks) {
if (hooks.before.size || hooks.instead.size || hooks.after.size) return;
if (target[method] && target[method][INSTALLED] === hooks) {
target[method] = hooks.original;
}
}
function attach(kind, target, method, hook) {
if (target == null) {
log8.error(`refusing to patch "${method}" on a null target`);
return () => {
};
}
let hooks;
try {
hooks = ensureInstalled(target, method);
} catch (err) {
log8.error(err);
return () => {
};
}
hooks[kind].add(hook);
let live = true;
return () => {
if (!live) return;
live = false;
hooks[kind].delete(hook);
maybeRestore(target, method, hooks);
};
}
var patcher = {
/** Run before the original. Edit `ctx.args` to change what it receives. */
before(target, method, hook) {
return attach("before", target, method, hook);
},
/** Run after the original. Edit `ctx.result` to change what callers see. */
after(target, method, hook) {
return attach("after", target, method, hook);
},
/** Replace the original. Call `ctx.callOriginal()` to defer to it. */
instead(target, method, hook) {
return attach("instead", target, method, hook);
}
};
// src/core/common/discord.ts
var Dispatcher = lazy(isFluxDispatcher);
function getDispatcher() {
for (const store of [GuildStore, ChannelStore, ReadStateStore]) {
try {
const viaStore = store?._dispatcher;
if (isFluxDispatcher(viaStore)) return viaStore;
} catch {
}
}
return find(isFluxDispatcher);
}
var MessageStore = lazy(
(m) => m?.getName?.() === "MessageStore" || typeof m?.getMessage === "function" && typeof m?.getMessages === "function" && typeof m?.__halcyon_probe__ === "undefined"
);
var MessageActions = lazy(
(m) => typeof m?.editMessage === "function" && typeof m?.deleteMessage === "function"
);
var UserStore = lazy(
(m) => m?.getName?.() === "UserStore" || typeof m?.getCurrentUser === "function" && typeof m?.getUser === "function" && typeof m?.__halcyon_probe__ === "undefined"
);
var ChannelStore = lazy(
(m) => m?.getName?.() === "ChannelStore" || m?.constructor?.displayName === "ChannelStore"
);
var SelectedChannelStore = lazy(
(m) => m?.getName?.() === "SelectedChannelStore" || typeof m?.getChannelId === "function" && typeof m?.getLastSelectedChannelId === "function" && typeof m?.__halcyon_probe__ === "undefined"
);
var GuildStore = lazy(
(m) => m?.getName?.() === "GuildStore" || m?.constructor?.displayName === "GuildStore"
);
var GuildChannelStore = lazy(
(m) => (
// Name-only, exactly like Vencord's findStoreLazy. A shape probe
// (getChannels/getDefaultChannel "look like" functions) also matches
// Discord's intl `t` proxy — which answers every property — so getChannels()
// returned {locale, ast, deleted} instead of real channels, and the scan
// collected zero. The proxy's getName() is a message object, never the
// string, so a name check rejects it.
m?.getName?.() === "GuildChannelStore"
)
);
var GuildSubscriptions = lazy(
(m) => typeof m?.subscribeToGuild === "function" || typeof m?.subscribeToChannel === "function"
);
var moment = lazy((m) => typeof m === "function" && typeof m?.locale === "function" && typeof m?.utc === "function");
var NavigationRouter = lazy(
(m) => typeof m?.transitionTo === "function" && // One companion method to avoid matching a bare transitionTo-only helper.
// NOT the full {replaceWith AND transitionToGuild} triple: on the current
// build the real router doesn't expose transitionToGuild, so requiring it
// made this resolve to NOTHING — which sent NavigationRouter callers down
// their fallback path (quest-indicator to `location.href`, i.e. a full page
// reload — the "任务中心变成刷新了" report — and the log-page jump to a warn).
// The intl-proxy is rejected by the __halcyon_probe__ guard alone, so the
// companion check only needs to be specific enough, not exhaustive.
(typeof m?.replaceWith === "function" || typeof m?.transitionToGuild === "function" || typeof m?.back === "function") && // Reject Discord's answer-everything intl `t` proxy, which reports EVERY
// property as callable — so it satisfies any method probe and would win.
typeof m?.__halcyon_probe__ === "undefined"
);
function navigate(path) {
try {
const router = NavigationRouter;
if (typeof router?.transitionTo === "function") {
router.transitionTo(path);
return true;
}
} catch {
}
let scanned;
try {
scanned = find(
(x) => typeof x?.transitionTo === "function" && typeof x?.__halcyon_probe__ === "undefined"
);
if (typeof scanned?.transitionTo === "function") {
scanned.transitionTo(path);
return true;
}
} catch {
}
try {
const candidates = [NavigationRouter, scanned];
try {
candidates.push(
find((x) => typeof x?.getHistory === "function" && typeof x?.__halcyon_probe__ === "undefined")
);
} catch {
}
for (const c of candidates) {
try {
const history2 = c?.getHistory?.();
if (history2 && typeof history2.push === "function") {
history2.push(path);
return true;
}
} catch {
}
}
} catch {
}
return false;
}
var AppLayers = lazy(
(m) => typeof m?.popLayer === "function" && typeof m?.pushLayer === "function" && // Same intl-proxy rejection as NavigationRouter above.
typeof m?.__halcyon_probe__ === "undefined"
);
var JumpActions = lazy(
(m) => typeof m?.jumpToMessage === "function" && typeof m?.__halcyon_probe__ === "undefined"
);
var RestAPI = lazy(
(m) => (
// EXACTLY Vencord's discriminator: an *object* carrying `del` AND `put`.
// This is what reliably picks Discord's real authenticated API client.
// Every earlier attempt failed on the wrong signal: requiring
// `getAPIBaseURL` matched nothing (this build doesn't expose it where our
// scan looks), and requiring get/post/put/del-as-functions matched a
// generic no-op HTTP client that answered 200 with an empty body and
// created nothing (the silent sticker-upload failure).
typeof m === "object" && typeof m?.del === "function" && typeof m?.put === "function" && // Reject Discord's intl `t` proxy, which answers EVERY property access with
// a message value — so del/put "look like" functions and it wins the probe.
// A real module returns undefined for a name it doesn't export; the
// answer-everything proxy returns a (truthy) message, failing this guard.
typeof m?.__halcyon_probe__ === "undefined"
)
);
var PermissionStore = lazy(
(m) => m?.getName?.() === "PermissionStore" && typeof m?.can === "function"
);
var EmojiStore = lazy((m) => m?.getName?.() === "EmojiStore");
var Constants = lazy(
(m) => typeof m?.Endpoints?.GUILD_STICKER_PACKS === "function"
);
var StickersStore = lazy((m) => m?.getName?.() === "StickersStore");
var QuestsStore = lazy((m) => m?.getName?.() === "QuestsStore");
var ReadStateStore = lazy(
(m) => (
// Name-only (see GuildChannelStore): the method-shape fallback also matched
// Discord's answer-everything intl proxy, so hasUnread() returned a truthy
// message object for every channel. The store's registered name is stable.
m?.getName?.() === "ReadStateStore"
)
);
var ActiveJoinedThreadsStore = lazy(
(m) => m?.getName?.() === "ActiveJoinedThreadsStore"
);
var Toasts = lazy(
(m) => typeof m?.showToast === "function" && typeof m?.createToast === "function" && // Reject Discord's intl `t` proxy, which answers EVERY property as a
// callable — so showToast/createToast "look like" functions and it wins the
// probe (which is why toasts silently never appeared). A real module returns
// undefined for a name it doesn't export; the answer-everything proxy does not.
typeof m?.__halcyon_probe__ === "undefined"
);
function showToast(message, type = "info") {
try {
const T = Toasts;
const typeEnum = T?.Type ?? {};
const resolved = type === "success" ? typeEnum.SUCCESS ?? 1 : type === "failure" ? typeEnum.FAILURE ?? 2 : typeEnum.MESSAGE ?? typeEnum.INFO ?? 0;
if (typeof T?.showToast === "function" && typeof T?.createToast === "function") {
T.showToast(T.createToast(message, resolved));
}
} catch {
}
}
// src/core/settings/index.ts
var log9 = logger("settings");
function deepClone(value) {
if (value === null || typeof value !== "object") return value;
return JSON.parse(JSON.stringify(value));
}
function defineSettings(schema) {
const listeners = /* @__PURE__ */ new Map();
let boundId = null;
const values = {};
for (const key of Object.keys(schema)) {
values[key] = deepClone(schema[key].default);
}
const persist = () => {
if (boundId) saveNamespace(boundId, values);
};
const notify = (key, next, prev) => {
const set = listeners.get(key);
if (!set) return;
for (const listener of set) {
try {
listener(next, prev);
} catch (err) {
log9.error(`settings listener for "${key}" threw`, err);
}
}
};
const store = new Proxy(values, {
get: (target, key) => target[key],
set: (target, key, value) => {
if (!(key in schema)) {
log9.warn(`ignoring write to unknown setting "${key}"`);
return true;
}
const prev = target[key];
if (Object.is(prev, value)) return true;
target[key] = value;
persist();
notify(key, value, prev);
return true;
}
});
return {
schema,
store,
subscribe(key, listener) {
const k = key;
let set = listeners.get(k);
if (!set) {
set = /* @__PURE__ */ new Set();
listeners.set(k, set);
}
set.add(listener);
return () => void set.delete(listener);
},
reset(key) {
if (key != null) {
store[key] = deepClone(schema[key].default);
return;
}
for (const k of Object.keys(schema)) {
store[k] = deepClone(schema[k].default);
}
},
__bind(pluginId) {
boundId = pluginId;
const saved = loadNamespace(pluginId);
for (const k of Object.keys(schema)) {
if (Object.prototype.hasOwnProperty.call(saved, k)) {
values[k] = saved[k];
}
}
}
};
}
// src/plugins/message-logger/settings.ts
var settings = defineSettings({
// --- 记录 -----------------------------------------------------------------
keepDeletedInChat: {
group: "\u8BB0\u5F55",
type: "boolean",
default: true,
label: "\u5728\u804A\u5929\u4E2D\u4FDD\u7559\u88AB\u5220\u6D88\u606F",
description: "\u88AB\u5220\u9664\u7684\u6D88\u606F\u4E0D\u518D\u6D88\u5931\uFF0C\u800C\u662F\u6807\u8BB0\u4FDD\u7559\u5728\u539F\u4F4D\u3002\u9700\u8981\u5BA2\u6237\u7AEF\u8865\u4E01\u751F\u6548\u3002"
},
toolbarButton: {
group: "\u8BB0\u5F55",
type: "boolean",
default: true,
label: "\u9891\u9053\u9876\u680F\u52A0\u300C\u6D88\u606F\u8BB0\u5F55\u300D\u6309\u94AE",
description: "\u5728\u9891\u9053\u53F3\u4E0A\u89D2\u5DE5\u5177\u6761\u653E\u4E00\u4E2A\u56FE\u6807\uFF0C\u70B9\u4E00\u4E0B\u76F4\u63A5\u6253\u5F00\u6D88\u606F\u8BB0\u5F55\u9875\uFF0C\u4E0D\u7528\u7FFB\u8BBE\u7F6E\u3002"
},
logEdits: {
group: "\u8BB0\u5F55",
type: "boolean",
default: true,
label: "\u8BB0\u5F55\u7F16\u8F91\u5386\u53F2",
description: "\u4FDD\u5B58\u6BCF\u6761\u6D88\u606F\u88AB\u7F16\u8F91\u524D\u7684\u5185\u5BB9\u3002"
},
retention: {
group: "\u8BB0\u5F55",
type: "number",
default: 50,
label: "\u6BCF\u9891\u9053\u4FDD\u7559\u6761\u6570",
description: "0 \u8868\u793A\u4E0D\u9650\u5236\u3002\u4E0A\u9650 500\u3002",
min: 0,
max: 500,
step: 10
},
// --- 外观 -----------------------------------------------------------------
deleteStyle: {
group: "\u5916\u89C2",
type: "select",
default: "tint",
label: "\u5220\u9664 / \u7F16\u8F91\u6837\u5F0F",
description: "\u88AB\u5220\u6D88\u606F\u3001\u4EE5\u53CA\u7F16\u8F91\u6D88\u606F\u4E0A\u65B9\u65E7\u7248\u672C\u5185\u5BB9\u5728\u804A\u5929\u4E2D\u7684\u5448\u73B0\u65B9\u5F0F\u3002",
options: [
{ value: "tint", label: "\u7EA2\u8272\u5E95\u7EB9 + \u5DE6\u4FA7\u7EA2\u6761" },
{ value: "text", label: "\u6B63\u6587\u53D8\u7EA2" },
{ value: "ghost", label: "\u534A\u900F\u660E\u6DE1\u51FA" },
{ value: "strike", label: "\u7EA2\u8272\u5220\u9664\u7EBF" }
]
},
showDeletedMarker: {
group: "\u5916\u89C2",
type: "boolean",
default: true,
label: "\u663E\u793A\u5220\u9664\u6807\u8BB0\u884C",
description: "\u5728\u88AB\u5220\u6D88\u606F\u4E0B\u65B9\u663E\u793A\u201C\u6B64\u6D88\u606F\u5DF2\u5220\u9664\u201D\u4E0E\u5220\u9664\u65F6\u95F4\u3002"
},
showEditedMarker: {
group: "\u5916\u89C2",
type: "boolean",
default: true,
label: "\u663E\u793A\u7F16\u8F91\u6807\u8BB0\u884C",
description: "\u5728\u7F16\u8F91\u8FC7\u7684\u6D88\u606F\u65C1\u663E\u793A\u201C\u6B64\u6D88\u606F\u5DF2\u7F16\u8F91\u201D\u4E0E\u7F16\u8F91\u65F6\u95F4\uFF08\u6CBF\u7528\u4E0B\u65B9\u6807\u8BB0\u7684\u56FE\u6807 / \u5916\u89C2 / \u65F6\u95F4\u8BBE\u7F6E\uFF09\u3002"
},
markerIcon: {
group: "\u5916\u89C2",
type: "select",
default: "trash",
label: "\u6807\u8BB0\u56FE\u6807",
description: "\u6807\u8BB0\u884C\u524D\u7684\u56FE\u6807\uFF08\u5220\u9664 / \u7F16\u8F91\u901A\u7528\uFF09\u3002",
options: [
{ value: "trash", label: "\u{1F5D1} \u5783\u573E\u6876" },
{ value: "shield", label: "\u{1F6E1} \u76FE\u724C" },
{ value: "warning", label: "\u26A0 \u8B66\u544A\u4E09\u89D2" },
{ value: "none", label: "\u65E0\u56FE\u6807" }
]
},
markerLook: {
group: "\u5916\u89C2",
type: "select",
default: "plain",
label: "\u6807\u8BB0\u5916\u89C2",
description: "\u6807\u8BB0\u884C\u7684\u5448\u73B0\u65B9\u5F0F\uFF08\u5220\u9664 / \u7F16\u8F91\u901A\u7528\uFF09\u3002",
options: [
{ value: "plain", label: "\u7EAF\u6587\u5B57" },
{ value: "badge", label: "\u5706\u89D2\u5FBD\u7AE0" },
{ value: "quote", label: "\u5F15\u7528\u5757\uFF08\u5DE6\u4FA7\u7AD6\u6761\uFF09" }
]
},
markerTime: {
group: "\u5916\u89C2",
type: "select",
default: "time",
label: "\u6807\u8BB0\u65F6\u95F4\u683C\u5F0F",
description: "\u6807\u8BB0\u884C\u91CC\u65F6\u95F4\u7684\u663E\u793A\u65B9\u5F0F\u3002",
options: [
{ value: "time", label: "\u4EC5\u65F6\u95F4\uFF0803:19:42\uFF09" },
{ value: "datetime", label: "\u65E5\u671F + \u65F6\u95F4" },
{ value: "none", label: "\u4E0D\u663E\u793A\u65F6\u95F4" }
]
},
// --- 屏蔽对象 ---------------------------------------------------------------
// Every rule below gates BOTH capture paths: the recorder (log page) and
// the in-chat red retention, via isIgnored().
ignoreBots: {
group: "\u5C4F\u853D\u5BF9\u8C61",
type: "boolean",
default: false,
label: "\u5C4F\u853D\u673A\u5668\u4EBA",
description: "\u673A\u5668\u4EBA\u7684\u6D88\u606F\u4E0D\u8BB0\u5F55\u3001\u4E0D\u5728\u804A\u5929\u4E2D\u4FDD\u7559\u3002"
},
ignoreSelf: {
group: "\u5C4F\u853D\u5BF9\u8C61",
type: "boolean",
default: false,
label: "\u5C4F\u853D\u81EA\u5DF1",
description: "\u4F60\u81EA\u5DF1\u5220\u9664\u6216\u7F16\u8F91\u7684\u6D88\u606F\u4E0D\u8BB0\u5F55\u3001\u4E0D\u5728\u804A\u5929\u4E2D\u4FDD\u7559\u3002"
},
ignoredUsers: {
group: "\u5C4F\u853D\u5BF9\u8C61",
type: "string-list",
default: [],
label: "\u5C4F\u853D\u7684\u7528\u6237",
description: "\u8FD9\u4E9B\u7528\u6237\u7684\u6D88\u606F\u4E0D\u8BB0\u5F55\u3001\u4E0D\u5728\u804A\u5929\u4E2D\u4FDD\u7559\u3002",
itemPlaceholder: "\u7528\u6237 ID"
},
ignoredChannels: {
group: "\u5C4F\u853D\u5BF9\u8C61",
type: "string-list",
default: [],
label: "\u5C4F\u853D\u7684\u9891\u9053",
description: "\u8FD9\u4E9B\u9891\u9053\u91CC\u7684\u6D88\u606F\u4E0D\u8BB0\u5F55\u3001\u4E0D\u5728\u804A\u5929\u4E2D\u4FDD\u7559\u3002",
itemPlaceholder: "\u9891\u9053 ID"
}
});
// src/plugins/message-logger/store.ts
var log10 = logger("message-logger");
var DATA_NS = "message-logger.log";
var MessageLogStore = class {
deleted = [];
edited = [];
retention = 50;
listeners = /* @__PURE__ */ new Set();
saveTimer;
/** `${channelId}:${id}` of every deleted entry — for per-render lookups. */
deletedIndex = /* @__PURE__ */ new Set();
/** Load persisted history. Safe to call before the first record. */
load() {
const raw = loadNamespace(DATA_NS);
this.deleted = Array.isArray(raw.deleted) ? raw.deleted : [];
this.edited = Array.isArray(raw.edited) ? raw.edited : [];
this.trimDeleted();
this.reindex();
}
/** O(1) "was this message deleted" — cheap enough for render paths. */
isDeleted(channelId, id) {
return this.deletedIndex.has(`${channelId}:${id}`);
}
/** The deleted-entry record for a message, if any. */
findDeleted(channelId, id) {
if (!this.isDeleted(channelId, id)) return void 0;
return this.deleted.find((d) => d.channelId === channelId && d.id === id);
}
setRetention(n) {
this.retention = Math.max(0, n | 0);
this.trimDeleted();
this.reindex();
this.scheduleSave();
this.emit();
}
recordDeleted(entry) {
if (this.deleted.some((d) => d.id === entry.id)) return;
this.deleted.unshift(entry);
this.trimDeleted();
this.reindex();
this.scheduleSave();
this.emit();
}
recordEdit(id, channelId, author, previous, guildId) {
const now = Date.now();
let entry = this.edited.find((e) => e.id === id);
if (!entry) {
entry = { id, channelId, guildId, author, history: [{ content: previous, at: now }], updatedAt: now };
this.edited.unshift(entry);
} else {
const last = entry.history[entry.history.length - 1];
if (last?.content === previous) return;
entry.history.push({ content: previous, at: now });
entry.updatedAt = now;
}
if (this.edited.length > 300) this.edited.length = 300;
this.scheduleSave();
this.emit();
}
getDeleted() {
return this.deleted;
}
getEdited() {
return this.edited;
}
counts() {
return { deleted: this.deleted.length, edited: this.edited.length };
}
clear() {
this.deleted = [];
this.edited = [];
this.reindex();
this.scheduleSave();
this.emit();
}
toJSON() {
return JSON.stringify({ deleted: this.deleted, edited: this.edited }, null, 2);
}
subscribe(listener) {
this.listeners.add(listener);
return () => void this.listeners.delete(listener);
}
/** Flush any pending save immediately (used on plugin stop). */
flush() {
if (this.saveTimer !== void 0) {
clearTimeout(this.saveTimer);
this.saveTimer = void 0;
}
this.save();
}
// --- internals -----------------------------------------------------------
trimDeleted() {
if (this.retention <= 0) return;
const perChannel = /* @__PURE__ */ new Map();
this.deleted = this.deleted.filter((d) => {
const seen = perChannel.get(d.channelId) ?? 0;
if (seen >= this.retention) return false;
perChannel.set(d.channelId, seen + 1);
return true;
});
}
reindex() {
this.deletedIndex = new Set(this.deleted.map((d) => `${d.channelId}:${d.id}`));
}
emit() {
for (const fn of this.listeners) {
try {
fn();
} catch {
}
}
}
scheduleSave() {
if (this.saveTimer !== void 0) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.save(), 500);
}
save() {
try {
saveNamespace(DATA_NS, { deleted: this.deleted, edited: this.edited });
} catch (err) {
log10.error("failed to persist message log", err);
}
}
};
var messageLog = new MessageLogStore();
// src/plugins/message-logger/render-content.tsx
var EMOJI_TOKEN = /<(a)?:([A-Za-z0-9_]+):(\d+)>/g;
function renderContent(content) {
const parts = [];
let cursor = 0;
let key = 0;
EMOJI_TOKEN.lastIndex = 0;
for (let m = EMOJI_TOKEN.exec(content); m; m = EMOJI_TOKEN.exec(content)) {
if (m.index > cursor) {
parts.push(/* @__PURE__ */ React.createElement("span", { key: key++ }, content.slice(cursor, m.index)));
}
const [, animated, name, id] = m;
parts.push(
/* @__PURE__ */ React.createElement(
"img",
{
key: key++,
className: "hc-emoji",
src: `https://cdn.discordapp.com/emojis/${id}.${animated ? "gif" : "webp"}`,
alt: `:${name}:`,
title: `:${name}:`,
draggable: false,
loading: "lazy"
}
)
);
cursor = m.index + m[0].length;
}
if (parts.length === 0) return content;
if (cursor < content.length) {
parts.push(/* @__PURE__ */ React.createElement("span", { key: key++ }, content.slice(cursor)));
}
return parts;
}
// src/plugins/message-logger/ui/LogPage.tsx
var log11 = logger("message-logger");
function useLog() {
const [snapshot, setSnapshot] = useState(() => ({
deleted: messageLog.getDeleted(),
edited: messageLog.getEdited()
}));
useEffect(() => {
const update = () => setSnapshot({ deleted: messageLog.getDeleted(), edited: messageLog.getEdited() });
update();
return messageLog.subscribe(update);
}, []);
return snapshot;
}
var PAGE_SIZE2 = 25;
function InChatStatus() {
const [snapshot, setSnapshot] = useState(
() => getSourcePatchReport().filter((p) => p.pluginId === "message-logger")
);
useEffect(() => {
const tick = () => setSnapshot(getSourcePatchReport().filter((p) => p.pluginId === "message-logger"));
tick();
const t = setInterval(tick, 3e3);
return () => clearInterval(t);
}, []);
if (snapshot.length === 0) return null;
const failed = snapshot.filter((p) => !p.applied);
if (failed.length === 0) return null;
const critical = failed.find((p) => p.label === "keep deleted message in store");
const title = critical ? "\u804A\u5929\u4E2D\u7684\u7EA2\u8272\u5360\u4F4D\u672A\u751F\u6548" : "\u90E8\u5206\u804A\u5929\u5185\u8865\u4E01\u672A\u5339\u914D\u5F53\u524D Discord \u7248\u672C";
const detail = critical ? "\u88AB\u5220\u9664\u7684\u6D88\u606F\u4ECD\u7136\u8BB0\u5F55\u5728\u4E0B\u65B9\u5217\u8868\uFF0C\u4F46\u5728\u804A\u5929\u91CC\u4F1A\u76F4\u63A5\u6D88\u5931\u3002\u6838\u5FC3\u8865\u4E01 keep-deleted \u672A\u5339\u914D\u5F53\u524D Discord \u7248\u672C\u3002" : "\u8BB0\u5F55\u529F\u80FD\u6B63\u5E38\uFF0C\u4F46\u804A\u5929\u4E2D\u7684\u7F16\u8F91\u5386\u53F2 / \u5220\u9664\u6807\u8BB0\u53EF\u80FD\u65E0\u6CD5\u663E\u793A\u3002";
return /* @__PURE__ */ React.createElement("div", { className: "hc-mlog-warn" }, /* @__PURE__ */ React.createElement("div", { className: "hc-mlog-warn__title" }, title), /* @__PURE__ */ React.createElement("div", { className: "hc-mlog-warn__detail" }, detail), /* @__PURE__ */ React.createElement("ul", { className: "hc-mlog-warn__list" }, failed.map((p) => /* @__PURE__ */ React.createElement("li", { key: p.label }, "\u201C", p.label, "\u201D"))), /* @__PURE__ */ React.createElement("div", { className: "hc-mlog-warn__detail" }, "\u8BF7\u628A\u6B64\u5904\u4EE5\u53CA\u65E5\u5FD7\u9875\u91CC \u201CHalcyon modules\u201D \u76F8\u5173\u7684\u8F93\u51FA\u53D1\u7ED9\u5F00\u53D1\u8005\u5B9A\u4F4D\u3002"));
}
function LogPage() {
const { deleted, edited } = useLog();
const [tab, setTab] = useState("deleted");
const [pages, setPages] = useState({ deleted: 0, edited: 0 });
const [query, setQuery] = useState("");
const all = tab === "deleted" ? deleted : edited;
const needle = query.trim().toLowerCase();
const entries = needle ? all.filter((e) => entryMatches(e, needle)) : all;
const pageCount = Math.max(1, Math.ceil(entries.length / PAGE_SIZE2));
const page = Math.min(pages[tab], pageCount - 1);
const visible = entries.slice(page * PAGE_SIZE2, (page + 1) * PAGE_SIZE2);
const goTo = (next) => setPages((prev) => ({ ...prev, [tab]: Math.max(0, Math.min(pageCount - 1, next)) }));
const onQuery = (value) => {
setQuery(value);
setPages((prev) => ({ ...prev, [tab]: 0 }));
};
return /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement(InChatStatus, null), /* @__PURE__ */ React.createElement("div", { className: "hc-tabs" }, /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-tab",
"data-active": tab === "deleted",
onClick: () => setTab("deleted")
},
/* @__PURE__ */ React.createElement(TrashIcon, { size: 16 }),
" \u5DF2\u5220\u9664",
deleted.length > 0 && /* @__PURE__ */ React.createElement(Badge, { tone: "red" }, deleted.length)
), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-tab",
"data-active": tab === "edited",
onClick: () => setTab("edited")
},
/* @__PURE__ */ React.createElement(PencilIcon, { size: 16 }),
" \u5DF2\u7F16\u8F91",
edited.length > 0 && /* @__PURE__ */ React.createElement(Badge, { tone: "orange" }, edited.length)
), /* @__PURE__ */ React.createElement("div", { className: "hc-tabs__spacer" }), /* @__PURE__ */ React.createElement(Button, { size: "sm", variant: "plain", icon: /* @__PURE__ */ React.createElement(DownloadIcon, { size: 16 }), onClick: exportLog }, "\u5BFC\u51FA"), /* @__PURE__ */ React.createElement(
Button,
{
size: "sm",
variant: "destructive",
onClick: () => messageLog.clear(),
disabled: all.length === 0
},
"\u6E05\u7A7A"
)), /* @__PURE__ */ React.createElement("div", { className: "hc-mlog-search" }, /* @__PURE__ */ React.createElement(SearchIcon, { size: 18 }), /* @__PURE__ */ React.createElement(
"input",
{
value: query,
onChange: (event) => onQuery(event.currentTarget.value),
placeholder: "\u641C\u7D22\u4F5C\u8005\u3001\u5185\u5BB9\u3001\u670D\u52A1\u5668 / \u9891\u9053",
"aria-label": "\u641C\u7D22\u6D88\u606F\u8BB0\u5F55"
}
), query && /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-mlog-search__clear",
"aria-label": "\u6E05\u9664\u641C\u7D22",
onClick: () => onQuery("")
},
"\xD7"
)), all.length === 0 ? tab === "deleted" ? /* @__PURE__ */ React.createElement(
EmptyState,
{
icon: /* @__PURE__ */ React.createElement(TrashIcon, { size: 48 }),
title: "\u8FD8\u6CA1\u6709\u8BB0\u5F55",
subtitle: "\u88AB\u5220\u9664\u7684\u6D88\u606F\u4F1A\u5728\u8FD9\u91CC\u4FDD\u7559\uFF0C\u542F\u7528\u63D2\u4EF6\u540E\u5373\u65F6\u751F\u6548\u3002"
}
) : /* @__PURE__ */ React.createElement(
EmptyState,
{
icon: /* @__PURE__ */ React.createElement(PencilIcon, { size: 48 }),
title: "\u8FD8\u6CA1\u6709\u7F16\u8F91\u8BB0\u5F55",
subtitle: "\u6D88\u606F\u88AB\u7F16\u8F91\u524D\u7684\u5185\u5BB9\u4F1A\u4FDD\u7559\u5728\u8FD9\u91CC\u3002"
}
) : entries.length === 0 ? /* @__PURE__ */ React.createElement(
EmptyState,
{
icon: /* @__PURE__ */ React.createElement(SearchIcon, { size: 48 }),
title: "\u6CA1\u6709\u5339\u914D\u7684\u8BB0\u5F55",
subtitle: `\u6CA1\u6709\u5305\u542B\u201C${query.trim()}\u201D\u7684\u8BB0\u5F55\uFF0C\u6362\u4E2A\u5173\u952E\u8BCD\u8BD5\u8BD5\u3002`
}
) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "hc-msglist" }, tab === "deleted" ? visible.map((entry) => /* @__PURE__ */ React.createElement(DeletedRow, { key: `${entry.channelId}-${entry.id}`, entry })) : visible.map((entry) => /* @__PURE__ */ React.createElement(EditedRow, { key: `${entry.channelId}-${entry.id}`, entry }))), pageCount > 1 && /* @__PURE__ */ React.createElement(Pager, { page, pageCount, onChange: goTo })));
}
function Pager(props) {
const { page, pageCount, onChange } = props;
return /* @__PURE__ */ React.createElement("div", { className: "hc-pager" }, /* @__PURE__ */ React.createElement(Button, { size: "sm", variant: "plain", onClick: () => onChange(page - 1), disabled: page === 0 }, "\u4E0A\u4E00\u9875"), /* @__PURE__ */ React.createElement("span", { className: "hc-pager__label" }, "\u7B2C ", page + 1, " / ", pageCount, " \u9875"), /* @__PURE__ */ React.createElement(
Button,
{
size: "sm",
variant: "plain",
onClick: () => onChange(page + 1),
disabled: page >= pageCount - 1
},
"\u4E0B\u4E00\u9875"
));
}
function jumpToMessage(channelId, messageId, guildId) {
dismissSettingsSurface();
let gid = guildId;
if (!gid) {
try {
const channel = ChannelStore.getChannel?.(channelId);
gid = channel?.guild_id ?? channel?.guildId ?? void 0;
} catch {
}
}
const path = `/channels/${gid ?? "@me"}/${channelId}/${messageId}`;
const selected = () => {
try {
return SelectedChannelStore.getChannelId?.();
} catch {
return void 0;
}
};
const doJump = () => {
const jump = JumpActions;
if (typeof jump?.jumpToMessage === "function") {
try {
jump.jumpToMessage({ channelId, messageId, flash: true });
if (selected() !== channelId) navigate(path);
return;
} catch (err) {
log11.warn("[jump] jumpToMessage threw; falling back to route", err);
}
}
if (!navigate(path)) {
log11.warn("[jump] \u8DF3\u8F6C\u5931\u8D25\uFF1AJumpActions \u4E0E NavigationRouter \u5747\u672A\u89E3\u6790\u5230");
}
};
const schedule = [80, 220, 450, 800];
let i = 0;
const tick = () => {
doJump();
const now = selected();
const ok = now === channelId;
log11.info(`[jump] \u7B2C ${i + 1} \u6B21 \xB7 now=${now ?? "?"} wanted=${channelId} ok=${ok}`);
i++;
if (!ok && i < schedule.length) {
setTimeout(tick, schedule[i] - schedule[i - 1]);
}
};
setTimeout(tick, schedule[0]);
}
function dismissSettingsSurface() {
try {
closeSettings();
} catch {
}
try {
const opts = { key: "Escape", code: "Escape", keyCode: 27, which: 27, bubbles: true, cancelable: true };
document.dispatchEvent(new KeyboardEvent("keydown", opts));
document.dispatchEvent(new KeyboardEvent("keyup", opts));
} catch (err) {
log11.error("[jump] escape dispatch failed", err);
}
try {
if (typeof AppLayers.popLayer === "function") {
AppLayers.popLayer();
} else {
getDispatcher()?.dispatch?.({ type: "LAYER_POP" });
}
} catch (err) {
log11.error("[jump] layer pop failed", err);
}
}
function JumpButton({ entry }) {
return /* @__PURE__ */ React.createElement(
Button,
{
size: "sm",
variant: "plain",
className: "hc-msg__jump",
icon: /* @__PURE__ */ React.createElement(ChevronRightIcon, { size: 16 }),
title: "\u8DF3\u8F6C\u5230\u8BE5\u6D88\u606F\u6240\u5728\u4F4D\u7F6E",
onClick: () => jumpToMessage(entry.channelId, entry.id, entry.guildId)
},
"\u8DF3\u8F6C"
);
}
function DeletedRow({ entry }) {
return /* @__PURE__ */ React.createElement("div", { className: "hc-msg" }, /* @__PURE__ */ React.createElement("div", { className: "hc-msg__head" }, /* @__PURE__ */ React.createElement("span", { className: "hc-msg__author" }, entry.author.name), entry.author.bot && /* @__PURE__ */ React.createElement(Badge, { tone: "neutral" }, "BOT"), /* @__PURE__ */ React.createElement(Location, { channelId: entry.channelId, guildId: entry.guildId }), /* @__PURE__ */ React.createElement("span", { className: "hc-msg__time" }, formatTime2(entry.deletedAt)), /* @__PURE__ */ React.createElement(JumpButton, { entry })), /* @__PURE__ */ React.createElement("div", { className: "hc-msg__body" }, entry.content ? renderContent(entry.content) : entry.stickers?.length ? /* @__PURE__ */ React.createElement("span", null, "\u{1F3F7}\uFE0F \u8D34\u7EB8\uFF1A", entry.stickers.map((s) => s.name).join("\u3001")) : entry.attachmentsRich?.length || entry.embeds?.length ? /* @__PURE__ */ React.createElement("span", null, "\u{1F5BC}\uFE0F \u5A92\u4F53\u6D88\u606F") : /* @__PURE__ */ React.createElement("span", { className: "hc-msg__empty" }, "\uFF08\u65E0\u6587\u672C\u5185\u5BB9\uFF09")), (entry.attachmentsRich?.length ?? 0) > 0 && /* @__PURE__ */ React.createElement("div", { className: "hc-msg__media" }, entry.attachmentsRich.map(
(a, i) => (a.content_type ?? "").startsWith("image/") || (a.content_type ?? "").startsWith("video/") ? /* @__PURE__ */ React.createElement(
"img",
{
key: i,
className: "hc-msg__thumb",
src: a.proxy_url ?? a.url,
alt: a.filename ?? "\u9644\u4EF6",
loading: "lazy"
}
) : /* @__PURE__ */ React.createElement("a", { key: i, href: a.url, target: "_blank", rel: "noreferrer" }, "\u{1F4CE} ", a.filename ?? "\u9644\u4EF6")
)), !entry.attachmentsRich?.length && entry.attachments.length > 0 && /* @__PURE__ */ React.createElement("div", { className: "hc-msg__meta" }, "\u9644\u4EF6 ", entry.attachments.length, " \u4E2A"));
}
function EditedRow({ entry }) {
return /* @__PURE__ */ React.createElement("div", { className: "hc-msg" }, /* @__PURE__ */ React.createElement("div", { className: "hc-msg__head" }, /* @__PURE__ */ React.createElement("span", { className: "hc-msg__author" }, entry.author.name), /* @__PURE__ */ React.createElement(Location, { channelId: entry.channelId, guildId: entry.guildId }), /* @__PURE__ */ React.createElement("span", { className: "hc-msg__time" }, formatTime2(entry.updatedAt)), /* @__PURE__ */ React.createElement(JumpButton, { entry })), /* @__PURE__ */ React.createElement("div", { className: "hc-msg__versions" }, entry.history.map((version2, index) => /* @__PURE__ */ React.createElement("div", { className: "hc-msg__version", key: index }, /* @__PURE__ */ React.createElement("span", { className: "hc-msg__vtag" }, "v", index + 1), /* @__PURE__ */ React.createElement("span", { className: "hc-msg__vbody" }, version2.content ? renderContent(version2.content) : "\uFF08\u7A7A\uFF09")))));
}
function resolveLocation(channelId, guildId) {
let channelName;
let gid = guildId;
let isDM = false;
try {
const channel2 = ChannelStore.getChannel?.(channelId);
if (channel2) {
if (channel2.name) channelName = String(channel2.name);
gid = gid ?? channel2.guild_id ?? channel2.guildId ?? void 0;
isDM = channel2.type === 1 || channel2.type === 3;
}
} catch {
}
let guildName;
try {
if (gid) {
const guild = GuildStore.getGuild?.(gid);
if (guild?.name) guildName = String(guild.name);
}
} catch {
}
const channel = channelName ? `#${channelName}` : isDM ? "\u79C1\u4FE1" : `#${channelId}`;
return { guild: guildName, channel };
}
function Location({ channelId, guildId }) {
const loc = resolveLocation(channelId, guildId);
return /* @__PURE__ */ React.createElement("span", { className: "hc-msg__where" }, loc.guild && /* @__PURE__ */ React.createElement("span", { className: "hc-msg__guild" }, loc.guild), loc.guild && /* @__PURE__ */ React.createElement("span", { className: "hc-msg__sep" }, "\u203A"), /* @__PURE__ */ React.createElement("span", null, loc.channel));
}
function entryMatches(entry, needle) {
try {
if (entry.author?.name && entry.author.name.toLowerCase().includes(needle)) return true;
const loc = resolveLocation(entry.channelId, entry.guildId);
if (loc.guild && loc.guild.toLowerCase().includes(needle)) return true;
if (loc.channel && loc.channel.toLowerCase().includes(needle)) return true;
if ("content" in entry && typeof entry.content === "string") {
if (entry.content.toLowerCase().includes(needle)) return true;
}
if ("history" in entry && Array.isArray(entry.history)) {
for (const v of entry.history) {
if (v?.content && v.content.toLowerCase().includes(needle)) return true;
}
}
} catch {
}
return false;
}
function formatTime2(time) {
const date = new Date(time);
const pad = (n) => String(n).padStart(2, "0");
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function exportLog() {
try {
const blob = new Blob([messageLog.toJSON()], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor2 = document.createElement("a");
anchor2.href = url;
anchor2.download = `halcyon-message-log-${Date.now()}.json`;
document.body.appendChild(anchor2);
anchor2.click();
anchor2.remove();
URL.revokeObjectURL(url);
} catch (err) {
log11.error("export failed", err);
}
}
// src/plugins/message-logger/toolbar-button.tsx
var log12 = logger("message-logger");
var TOOLBAR_ANCHORS = [
'section[class*="title_"] [class*="toolbar_"]',
'section[class*="title"] [class*="toolbar"]',
'[class*="chat_"] [class*="toolbar_"]',
'[class*="toolbar_"]'
];
var ENSURE_MS = 1e3;
var host2 = null;
var unmount2 = null;
var timer;
var unsubscribe;
function LogButton() {
return /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-mlog-toolbtn",
"aria-label": "\u6D88\u606F\u8BB0\u5F55",
title: "\u6D88\u606F\u8BB0\u5F55\uFF08\u88AB\u5220 / \u7F16\u8F91\uFF09",
onClick: () => openSettings({ pluginId: "message-logger" })
},
/* @__PURE__ */ React.createElement(ClockIcon, { size: 24 })
);
}
function findToolbar() {
for (const selector of TOOLBAR_ANCHORS) {
try {
const el = document.querySelector(selector);
if (el) return el;
} catch {
}
}
return null;
}
function ensureMounted() {
if (!settings.store.toolbarButton) {
teardown();
return;
}
if (host2 && document.contains(host2)) return;
if (host2) teardown();
const toolbar = findToolbar();
if (!toolbar) return;
const el = document.createElement("div");
el.className = "hc-mlog-toolbtn-host";
el.setAttribute("data-hc-plugin", "message-logger");
try {
toolbar.insertBefore(el, toolbar.firstChild);
} catch {
return;
}
try {
const off = mountDetached(React.createElement(LogButton), el);
host2 = el;
unmount2 = off;
} catch (err) {
el.remove();
log12.debug("toolbar button mount failed", err);
}
}
function teardown() {
if (unmount2) {
try {
unmount2();
} catch {
}
unmount2 = null;
}
if (host2) {
host2.remove();
host2 = null;
}
}
function startToolbarButton() {
injectStyles();
stopToolbarButton();
ensureMounted();
timer = setInterval(ensureMounted, ENSURE_MS);
unsubscribe = settings.subscribe("toolbarButton", () => ensureMounted());
}
function stopToolbarButton() {
if (timer) {
clearInterval(timer);
timer = void 0;
}
unsubscribe?.();
unsubscribe = void 0;
teardown();
}
// src/plugins/message-logger/index.tsx
var log13 = logger("message-logger");
var unpatchDispatch;
var unsubscribeRetention;
var unsubscribeDeleteStyle;
function toMillis(value) {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? Date.now() : parsed;
}
if (value && typeof value.valueOf === "function") {
const raw = value.valueOf();
if (typeof raw === "number") return raw;
}
return Date.now();
}
function displayName(author) {
return author?.globalName || author?.global_name || author?.username || author?.name || "\u672A\u77E5\u7528\u6237";
}
function toAuthor(author) {
return { id: String(author?.id ?? "0"), name: displayName(author), bot: Boolean(author?.bot) };
}
function attachmentsOf(message) {
const list = message?.attachments;
if (!Array.isArray(list)) return [];
return list.map((a) => a?.filename || a?.url || "\u9644\u4EF6").slice(0, 20);
}
function richAttachmentsOf(message) {
const list = message?.attachments;
if (!Array.isArray(list)) return [];
return list.map((a) => ({
id: a?.id != null ? String(a.id) : void 0,
filename: a?.filename ?? a?.fileName ?? void 0,
url: a?.url ?? void 0,
proxy_url: a?.proxy_url ?? a?.proxyURL ?? a?.proxyUrl ?? void 0,
content_type: a?.content_type ?? a?.contentType ?? void 0,
width: typeof a?.width === "number" ? a.width : void 0,
height: typeof a?.height === "number" ? a.height : void 0,
size: typeof a?.size === "number" ? a.size : void 0
})).filter((a) => a.url || a.proxy_url).slice(0, 10);
}
function embedsOf(message) {
const list = message?.embeds;
if (!Array.isArray(list) || list.length === 0) return [];
try {
return JSON.parse(JSON.stringify(list)).slice(0, 6);
} catch {
return [];
}
}
function stickersOf(message) {
const list = message?.sticker_items ?? message?.stickerItems ?? message?.stickers;
if (!Array.isArray(list)) return [];
return list.filter((s) => s?.id != null).map((s) => ({
id: String(s.id),
name: String(s.name ?? "\u8D34\u7EB8"),
format_type: typeof s.format_type === "number" ? s.format_type : s.formatType
})).slice(0, 4);
}
function currentUserId() {
try {
return UserStore.getCurrentUser?.()?.id;
} catch {
return void 0;
}
}
var selfIgnoreLogged = false;
function isIgnored(channelId, author) {
const s = settings.store;
if (channelId && s.ignoredChannels.includes(channelId)) return true;
const authorId = author?.id != null ? String(author.id) : "";
if (authorId && s.ignoredUsers.includes(authorId)) return true;
if (s.ignoreBots && author?.bot) return true;
if (s.ignoreSelf) {
const me = currentUserId();
if (!selfIgnoreLogged) {
selfIgnoreLogged = true;
const hit = Boolean(authorId && me && authorId === String(me));
log13.info(
`\u5C4F\u853D\u81EA\u5DF1 \u81EA\u68C0 \u2014 \u5F00\u5173=on\uFF0C\u6D88\u606F\u4F5C\u8005id=${authorId || "(\u7A7A)"}\uFF0C\u5F53\u524D\u7528\u6237id=${me ?? "(\u53D6\u4E0D\u5230)"}\uFF0C\u5224\u5B9A=${hit ? "\u547D\u4E2D\u2192\u4F1A\u5C4F\u853D" : "\u672A\u547D\u4E2D\u2192\u4E0D\u5C4F\u853D"}`
);
}
if (authorId && me && authorId === String(me)) return true;
}
return false;
}
var shadow = /* @__PURE__ */ new Map();
var SHADOW_MAX = 4e3;
function remember(channelId, id, message) {
const content = message?.content;
if (!channelId || !id || typeof content !== "string") return;
const key = `${channelId}:${id}`;
const prior = shadow.get(key);
if (prior) shadow.delete(key);
const stickers = stickersOf(message);
const rich = richAttachmentsOf(message);
const embeds = embedsOf(message);
shadow.set(key, {
content,
// Partial payloads (some MESSAGE_UPDATEs) may omit these; keep what we had.
author: message?.author ?? prior?.author,
attachments: Array.isArray(message?.attachments) ? attachmentsOf(message) : prior?.attachments,
attachmentsRich: rich.length ? rich : prior?.attachmentsRich,
embeds: embeds.length ? embeds : prior?.embeds,
stickers: stickers.length ? stickers : prior?.stickers,
sentAt: message?.timestamp != null ? toMillis(message.timestamp) : prior?.sentAt,
guildId: message?.guild_id ?? message?.guildId ?? prior?.guildId
});
if (shadow.size > SHADOW_MAX) {
const oldest = shadow.keys().next().value;
if (oldest !== void 0) shadow.delete(oldest);
}
}
function readMessage(channelId, id) {
try {
return MessageStore.getMessage(channelId, id);
} catch {
return void 0;
}
}
var tintObserver;
var tintInterval;
var sweepScheduled = false;
function sweepDeletedRows() {
try {
if (typeof document === "undefined") return;
const root = document.documentElement;
const want = `hc-mlog-${settings.store.deleteStyle || "tint"}`;
if (root && !root.classList.contains(want)) {
for (const s of DELETE_STYLE_CLASSES) root.classList.remove(`hc-mlog-${s}`);
root.classList.add(want);
}
const rows = document.querySelectorAll('li[id^="chat-messages-"]');
rows.forEach((el) => {
if (!el.classList.contains("hc-deleted") && rowIsDeleted(el)) el.classList.add("hc-deleted");
});
} catch {
}
}
function scheduleSweep() {
if (sweepScheduled) return;
sweepScheduled = true;
setTimeout(() => {
sweepScheduled = false;
sweepDeletedRows();
}, 60);
}
function rowIsDeleted(el) {
const parts = el.id.split("-");
const messageId = parts[parts.length - 1];
const channelId = parts.length >= 4 ? parts[parts.length - 2] : void 0;
return channelId ? messageLog.isDeleted(channelId, messageId) : messageLog.getDeleted().some((d) => d.id === messageId);
}
function startDomTinter() {
if (typeof MutationObserver === "undefined" || typeof document === "undefined") return;
tintObserver = new MutationObserver((mutations) => {
for (const mu of mutations) {
const t = mu.target;
if (mu.type === "attributes" && t instanceof Element && t.id && t.id.startsWith("chat-messages-") && !t.classList.contains("hc-deleted") && rowIsDeleted(t)) {
t.classList.add("hc-deleted");
}
}
scheduleSweep();
});
const attach4 = () => {
const target = document.documentElement ?? document.body;
if (!target) return false;
sweepDeletedRows();
tintObserver?.observe(target, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class"]
});
return true;
};
if (!attach4()) {
let tries = 0;
const timer3 = setInterval(() => {
if (attach4() || ++tries > 100) clearInterval(timer3);
}, 100);
}
if (tintInterval) clearInterval(tintInterval);
tintInterval = setInterval(sweepDeletedRows, 300);
}
function stopDomTinter() {
tintObserver?.disconnect();
tintObserver = void 0;
if (tintInterval) {
clearInterval(tintInterval);
tintInterval = void 0;
}
}
function tintRowInDom(channelId, messageId) {
try {
const el = document.getElementById(`chat-messages-${channelId}-${messageId}`) || document.getElementById(`chat-messages-${messageId}`);
if (el) el.classList.add("hc-deleted");
} catch {
}
scheduleSweep();
}
function rawAuthorFor(authorId, fallbackName, fallbackBot) {
try {
const u = UserStore.getUser?.(authorId);
if (u) {
const author = {
id: String(u.id),
username: u.username ?? fallbackName,
global_name: u.globalName ?? u.global_name ?? null,
discriminator: String(u.discriminator ?? "0"),
bot: Boolean(u.bot),
public_flags: u.publicFlags ?? u.public_flags ?? 0
};
if (u.avatar !== void 0) author.avatar = u.avatar;
const deco = u.avatarDecorationData ?? u.avatar_decoration_data;
if (deco !== void 0) author.avatar_decoration_data = deco;
return author;
}
} catch {
}
return {
id: String(authorId || "0"),
username: fallbackName,
global_name: fallbackName,
discriminator: "0",
bot: fallbackBot
};
}
function liveRepaintPatchesApplied() {
try {
const mine = getSourcePatchReport().filter((p) => p.pluginId === "message-logger");
const needed = ["re-render on deleted flag", "declare deleted field on message record"];
return needed.every((label) => mine.some((p) => p.label === label && p.applied));
} catch {
return false;
}
}
var rerendered = /* @__PURE__ */ new Set();
function forceRowRerender(channelId, messageId) {
try {
const dispatcher2 = getDispatcher();
if (!dispatcher2 || typeof dispatcher2.dispatch !== "function") return;
const msg = readMessage(channelId, messageId);
if (!msg) return;
const a = msg.author ?? {};
const iso = (v) => {
if (v == null) return null;
if (typeof v?.toISOString === "function") return v.toISOString();
if (typeof v === "string") return v;
return new Date(toMillis(v)).toISOString();
};
const entry = messageLog.findDeleted(channelId, messageId);
let embeds = embedsOf(msg);
if ((!embeds || embeds.length === 0) && entry?.embeds?.length) embeds = entry.embeds;
let stickerItems = stickersOf(msg);
if (stickerItems.length === 0 && entry?.stickers?.length) stickerItems = entry.stickers;
let rich = richAttachmentsOf(msg);
if (rich.length === 0 && entry?.attachmentsRich?.length) rich = entry.attachmentsRich;
const content = typeof msg.content === "string" && msg.content !== "" ? msg.content : entry?.content ?? "";
const raw = {
id: String(messageId),
channel_id: String(channelId),
guild_id: msg.guild_id ?? msg.guildId ?? entry?.guildId ?? null,
type: typeof msg.type === "number" ? msg.type : 0,
content,
// Echo the REAL user (real avatar hash) so this update can't wipe it.
author: rawAuthorFor(
String(a.id ?? entry?.author.id ?? "0"),
a.username ?? a.global_name ?? a.globalName ?? entry?.author.name ?? "user",
Boolean(a.bot ?? entry?.author.bot)
),
timestamp: iso(msg.timestamp) ?? (/* @__PURE__ */ new Date()).toISOString(),
edited_timestamp: iso(msg.editedTimestamp ?? msg.edited_timestamp),
tts: Boolean(msg.tts),
mention_everyone: Boolean(msg.mentionEveryone ?? msg.mention_everyone),
mentions: [],
mention_roles: [],
attachments: rich.map((x, i) => ({
id: x.id ?? `${messageId}${i}`,
filename: x.filename ?? "file",
url: x.url ?? x.proxy_url,
proxy_url: x.proxy_url ?? x.url,
content_type: x.content_type,
width: x.width,
height: x.height,
size: x.size ?? 0
})),
embeds,
sticker_items: stickerItems,
pinned: Boolean(msg.pinned),
flags: typeof msg.flags === "number" ? msg.flags : 0,
// Carried so the rebuilt record keeps the flag where our patch preserves it.
deleted: true
};
dispatcher2.dispatch({ type: "MESSAGE_UPDATE", message: raw });
} catch (err) {
log13.debug("force row re-render failed (non-fatal)", err);
}
}
function scheduleRerender(channelId, messageId) {
if (liveRepaintPatchesApplied()) return;
const key = `${channelId}:${messageId}`;
if (rerendered.has(key)) return;
rerendered.add(key);
setTimeout(() => {
forceRowRerender(channelId, messageId);
setTimeout(() => rerendered.delete(key), 1500);
}, 0);
}
function captureDelete(channelId, id) {
if (!channelId || !id) return;
const message = readMessage(channelId, id);
const snap = shadow.get(`${channelId}:${id}`);
if (!message && !snap) {
log13.debug(`delete of ${id} skipped: message not in cache or shadow`);
return;
}
const author = message?.author ?? snap?.author ?? {};
if (isIgnored(channelId, author)) return;
const content = typeof message?.content === "string" && message.content !== "" ? message.content : snap?.content ?? "";
const attachments = message ? attachmentsOf(message) : snap?.attachments ?? [];
const liveRich = message ? richAttachmentsOf(message) : [];
const attachmentsRich = liveRich.length ? liveRich : snap?.attachmentsRich ?? [];
const liveEmbeds = message ? embedsOf(message) : [];
const embeds = liveEmbeds.length ? liveEmbeds : snap?.embeds ?? [];
const liveStickers = message ? stickersOf(message) : [];
const stickers = liveStickers.length ? liveStickers : snap?.stickers ?? [];
if (!content && attachments.length === 0 && attachmentsRich.length === 0 && embeds.length === 0 && stickers.length === 0) return;
messageLog.recordDeleted({
id: String(id),
channelId: String(channelId),
guildId: message?.guild_id ?? message?.guildId ?? snap?.guildId ?? void 0,
author: toAuthor(author),
content,
attachments,
attachmentsRich: attachmentsRich.length ? attachmentsRich : void 0,
embeds: embeds.length ? embeds : void 0,
stickers: stickers.length ? stickers : void 0,
sentAt: message?.timestamp != null ? toMillis(message.timestamp) : snap?.sentAt ?? Date.now(),
deletedAt: Date.now()
});
if (message && settings.store.keepDeletedInChat) {
try {
message.deleted = true;
} catch {
}
}
if (settings.store.keepDeletedInChat) {
tintRowInDom(String(channelId), String(id));
scheduleRerender(String(channelId), String(id));
}
if (settings.store.keepDeletedInChat && !liveKeepChecked) {
liveKeepChecked = true;
const cid = String(channelId);
const mid = String(id);
setTimeout(() => {
const still = readMessage(cid, mid);
const domEl = typeof document !== "undefined" ? document.getElementById(`chat-messages-${cid}-${mid}`) || document.getElementById(`chat-messages-${mid}`) : null;
const tinted = !!domEl && domEl.classList.contains("hc-deleted");
if (still && still.deleted === true) {
log13.info(
`live keep-deleted \u81EA\u68C0 OK \u2014 \u88AB\u5220\u6D88\u606F\u4ECD\u7559\u5728 store \u4E14\u5DF2\u6807\u8BB0 deleted\uFF1BDOM \u884C${domEl ? tinted ? "\u5DF2\u76F4\u63A5\u67D3\u7EA2\uFF08\u5B9E\u65F6\u7EA2\u6761\u751F\u6548\uFF09" : "\u627E\u5230\u4F46\u672A\u67D3\u7EA2\uFF0C\u8BF7\u53CD\u9988" : "\u672A\u627E\u5230\uFF08\u53EF\u80FD\u5DF2\u6EDA\u51FA\u89C6\u56FE\uFF09"}`
);
} else if (still) {
log13.warn("live keep-deleted \u81EA\u68C0 PARTIAL \u2014 \u6D88\u606F\u4FDD\u7559\u4F46\u672A\u6807\u8BB0 deleted\uFF0C\u6539\u7528 DOM \u76F4\u63A5\u67D3\u7EA2\u515C\u5E95");
} else {
log13.error(
"live keep-deleted \u81EA\u68C0 FAILED \u2014 MessageStore \u5DF2\u4E22\u5F03\u88AB\u5220\u6D88\u606F\uFF0C\u8BF4\u660E \u201Ckeep deleted message in store\u201D \u8865\u4E01\u672A\u547D\u4E2D\u5F53\u524D\u6784\u5EFA\uFF1B\u88AB\u5220\u6D88\u606F\u53EA\u4F1A\u5728\u91CD\u65B0\u52A0\u8F7D\u9891\u9053\u540E\u7531 revive \u91CD\u65B0\u51FA\u73B0\uFF08\u6B63\u662F\u4F60\u8BF4\u7684\u201C\u5237\u65B0\u624D\u6709\u3001\u5B9E\u65F6\u6CA1\u6709\u201D\uFF09\u3002"
);
}
}, 0);
}
}
function captureEdit(payload) {
if (!settings.store.logEdits || !payload) return;
const channelId = payload.channel_id ?? payload.channelId;
const id = payload.id;
if (!channelId || !id) return;
if (typeof payload.content !== "string") return;
const key = `${channelId}:${id}`;
const existing = readMessage(channelId, id);
const snap = shadow.get(key);
const previous = snap?.content ?? (typeof existing?.content === "string" ? existing.content : void 0);
remember(channelId, id, payload);
if (previous === void 0) {
log13.debug(`edit to ${id} skipped: no prior content known (message predates the recorder)`);
return;
}
if (previous === payload.content) return;
const author = existing?.author ?? snap?.author ?? payload.author ?? {};
if (isIgnored(channelId, author)) return;
const guildId = payload.guild_id ?? payload.guildId ?? existing?.guild_id ?? snap?.guildId;
messageLog.recordEdit(
String(id),
String(channelId),
toAuthor(author),
previous,
guildId != null ? String(guildId) : void 0
);
}
function entryToRaw(entry) {
const attachments = (entry.attachmentsRich ?? []).map((a, i) => ({
id: a.id ?? `${entry.id}${i}`,
filename: a.filename ?? "attachment",
url: a.url ?? a.proxy_url,
proxy_url: a.proxy_url ?? a.url,
content_type: a.content_type,
width: a.width,
height: a.height,
size: a.size ?? 0,
spoiler: false
}));
return {
id: entry.id,
type: 0,
channel_id: entry.channelId,
guild_id: entry.guildId,
sticker_items: entry.stickers?.length ? entry.stickers : void 0,
content: entry.content || (attachments.length === 0 && entry.attachments.length ? `\u{1F4CE} ${entry.attachments.join(", ")}` : ""),
// Echo the real user so resurrecting a deleted message on reload can't null
// that user's avatar across the client (the "吞头像" bug).
author: rawAuthorFor(entry.author.id, entry.author.name, entry.author.bot),
timestamp: new Date(entry.sentAt).toISOString(),
attachments,
embeds: entry.embeds ?? [],
mentions: [],
mention_roles: [],
mention_everyone: false,
pinned: false,
tts: false,
flags: 0
};
}
function compareIds(a, b) {
try {
const x = BigInt(a);
const y = BigInt(b);
return x < y ? -1 : x > y ? 1 : 0;
} catch {
return a < b ? -1 : a > b ? 1 : 0;
}
}
var injectedActions = /* @__PURE__ */ new WeakSet();
function resurrectIntoLoad(action) {
if (!settings.store.keepDeletedInChat) return;
if (injectedActions.has(action)) return;
injectedActions.add(action);
const channelId = String(action.channelId ?? action.channel_id ?? "");
const msgs = action.messages;
if (!channelId || !Array.isArray(msgs)) return;
const mine = messageLog.getDeleted().filter((d) => d.channelId === channelId);
if (!mine.length) return;
const present = new Set(msgs.map((m) => String(m?.id)));
let minId;
for (const m of msgs) {
const id = m?.id != null ? String(m.id) : void 0;
if (id && (minId === void 0 || compareIds(id, minId) < 0)) minId = id;
}
const revived = mine.filter(
(d) => !present.has(d.id) && (minId === void 0 || compareIds(d.id, minId) >= 0) && // Respect the ignore rules at revive time too, so toggling "屏蔽机器人"
// or "屏蔽自己" takes effect for already-recorded messages on reload.
!isIgnored(channelId, d.author)
);
if (!revived.length) return;
const descending = msgs.length >= 2 ? compareIds(String(msgs[0].id), String(msgs[msgs.length - 1].id)) > 0 : true;
msgs.push(...revived.map(entryToRaw));
msgs.sort((a, b) => {
const c = compareIds(String(a?.id ?? "0"), String(b?.id ?? "0"));
return descending ? -c : c;
});
log13.info(`revived ${revived.length} deleted message(s) into ${channelId}`);
}
function reflagLoaded(action) {
if (!settings.store.keepDeletedInChat) return;
const channelId = String(action.channelId ?? action.channel_id ?? "");
if (!channelId) return;
for (const d of messageLog.getDeleted()) {
if (d.channelId !== channelId) continue;
const msg = readMessage(channelId, d.id);
if (msg && !msg.deleted) {
try {
msg.deleted = true;
} catch {
}
}
}
}
function trackContent(action, type) {
try {
if (type === "MESSAGE_CREATE") {
const m = action.message;
remember(m?.channel_id ?? m?.channelId ?? action.channelId, m?.id, m);
} else if (type === "LOAD_MESSAGES_SUCCESS") {
const channelId = action.channelId ?? action.channel_id;
if (Array.isArray(action.messages)) {
for (const m of action.messages) remember(m?.channel_id ?? channelId, m?.id, m);
}
}
} catch {
}
}
var firstCaptureLogged = false;
var actionsSeen = 0;
var liveKeepChecked = false;
function onAction(action) {
const type = action?.type;
if (typeof type !== "string") return;
if (WATCHED.includes(type)) actionsSeen++;
trackContent(action, type);
if (type === "LOAD_MESSAGES_SUCCESS") {
try {
resurrectIntoLoad(action);
setTimeout(() => reflagLoaded(action), 0);
} catch (err) {
log13.error("failed to revive deleted messages on channel load", err);
}
}
try {
if (type === "MESSAGE_DELETE") {
captureDelete(action.channelId ?? action.channel_id, action.id ?? action.messageId);
} else if (type === "MESSAGE_DELETE_BULK") {
const channelId = action.channelId ?? action.channel_id;
for (const id of action.ids ?? []) captureDelete(channelId, id);
} else if (type === "MESSAGE_UPDATE") {
captureEdit(action.message);
} else {
return;
}
if (!firstCaptureLogged) {
firstCaptureLogged = true;
log13.info(`recorder saw its first ${type}`);
}
} catch (err) {
log13.error("recorder failed for", type, err);
}
}
function onDispatch(ctx) {
onAction(ctx.args[0]);
}
var WATCHED = ["MESSAGE_CREATE", "MESSAGE_UPDATE", "MESSAGE_DELETE", "MESSAGE_DELETE_BULK", "LOAD_MESSAGES_SUCCESS"];
function attachRecorder(dispatcher2, tag) {
const undo = [];
const seams = [];
if (typeof dispatcher2.addInterceptor === "function") {
try {
const interceptor = (action) => {
onAction(action);
return false;
};
dispatcher2.addInterceptor(interceptor);
undo.push(() => {
const list = dispatcher2._interceptors;
if (Array.isArray(list)) {
const at = list.indexOf(interceptor);
if (at >= 0) list.splice(at, 1);
}
});
seams.push("interceptor");
} catch {
}
}
for (const method of ["dispatch", "_dispatch"]) {
if (typeof dispatcher2[method] === "function") {
try {
undo.push(patcher.before(dispatcher2, method, onDispatch));
seams.push(method);
} catch {
}
break;
}
}
if (typeof dispatcher2.subscribe === "function") {
try {
const handler = (action) => onAction(action);
for (const type of WATCHED) dispatcher2.subscribe(type, handler);
undo.push(() => {
if (typeof dispatcher2.unsubscribe === "function") {
for (const type of WATCHED) {
try {
dispatcher2.unsubscribe(type, handler);
} catch {
}
}
}
});
seams.push("subscribe");
} catch {
}
}
log13.info(`recorder on dispatcher ${tag}: seams [${seams.join(", ") || "none"}]`);
return () => undo.forEach((u) => u());
}
function attachRecorderEverywhere() {
const hooked = /* @__PURE__ */ new Set();
const undo = [];
const sweep = () => {
const candidates = [...findAll(isFluxDispatcher), getDispatcher()].filter(Boolean);
let added = 0;
for (const d of candidates) {
if (hooked.has(d)) continue;
hooked.add(d);
undo.push(attachRecorder(d, `#${hooked.size}`));
added++;
}
return added;
};
const first = sweep();
log13.info(`recorder attached to ${first} dispatcher instance(s)`);
const timer3 = setInterval(() => {
const added = sweep();
if (added > 0) log13.info(`recorder attached to ${added} late dispatcher instance(s)`);
}, 5e3);
const stopTimer = setTimeout(() => clearInterval(timer3), 6e4);
return () => {
clearInterval(timer3);
clearTimeout(stopTimer);
undo.forEach((u) => u());
};
}
var MARKER_ICON_PATHS = {
trash: () => /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("path", { d: "M4.5 7h15" }), /* @__PURE__ */ React.createElement("path", { d: "M9.25 7V5.5A1.5 1.5 0 0110.75 4h2.5a1.5 1.5 0 011.5 1.5V7" }), /* @__PURE__ */ React.createElement("path", { d: "M6.5 7l.85 11.1A2 2 0 009.34 20h5.32a2 2 0 001.99-1.9L17.5 7" })),
shield: () => /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("path", { d: "M12 3.5l7 2.6v5c0 4.4-3 7.3-7 8.9-4-1.6-7-4.5-7-8.9v-5l7-2.6z" }), /* @__PURE__ */ React.createElement("path", { d: "M9.5 12l1.8 1.8 3.2-3.6" })),
warning: () => /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("path", { d: "M12 4.5L3.5 19h17L12 4.5z" }), /* @__PURE__ */ React.createElement("path", { d: "M12 10v4" }), /* @__PURE__ */ React.createElement("path", { d: "M12 16.75h.01" }))
};
function formatDeletedAt(at, mode) {
if (at == null || mode === "none") return void 0;
const d = new Date(at);
if (mode === "datetime") {
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${d.toLocaleTimeString("zh-CN", { hour12: false })}`;
}
return d.toLocaleTimeString("zh-CN", { hour12: false });
}
function MessageMarker(props) {
const s = settings.store;
const icon = MARKER_ICON_PATHS[s.markerIcon]?.();
const stamp = formatDeletedAt(props.at, s.markerTime);
const cls = `hc-deleted-marker hc-deleted-marker--${s.markerLook || "plain"}` + (props.edited ? " hc-deleted-marker--edited" : "");
return /* @__PURE__ */ React.createElement("div", { className: cls }, icon && /* @__PURE__ */ React.createElement(
"svg",
{
className: "hc-deleted-marker__icon",
width: "14",
height: "14",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true
},
icon
), /* @__PURE__ */ React.createElement("span", null, props.text, stamp ? `\uFF08${stamp}\uFF09` : ""));
}
var MARKER_SETTING_KEYS = ["logEdits", "deleteStyle", "showDeletedMarker", "showEditedMarker", "markerIcon", "markerLook", "markerTime"];
function useMlogSettings() {
const [, bump] = useState(0);
useEffect(() => {
const unsubs = MARKER_SETTING_KEYS.map((key) => settings.subscribe(key, () => bump((n) => n + 1)));
return () => unsubs.forEach((unsub) => unsub());
}, []);
}
function collectDeletedMedia(attachments, embeds) {
const out = [];
for (const a of attachments ?? []) {
const url = a.proxy_url ?? a.url;
if (!url) continue;
const ct = a.content_type ?? "";
out.push({
url,
kind: ct.startsWith("video/") ? "video" : ct.startsWith("image/") ? "image" : "file",
name: a.filename
});
}
for (const e of embeds ?? []) {
const img = e?.image?.proxy_url ?? e?.image?.url ?? e?.thumbnail?.proxy_url ?? e?.thumbnail?.url;
if (typeof img === "string" && img) out.push({ url: img, kind: "image" });
}
return out.slice(0, 6);
}
function MessageExtras(props) {
useMlogSettings();
const s = settings.store;
const nodes = [];
if (s.logEdits && props.history && props.history.length > 0) {
nodes.push(
/* @__PURE__ */ React.createElement("div", { className: "hc-edit-history", key: "hc-edit-history" }, props.history.map((version2, index) => {
const time = formatDeletedAt(version2.at, "time");
return /* @__PURE__ */ React.createElement(
"div",
{
className: `hc-edit-history__version hc-edit-history__version--${s.deleteStyle || "tint"}`,
key: index
},
renderContent(version2.content),
time ? /* @__PURE__ */ React.createElement("span", { className: "hc-edit-history__time" }, time) : null
);
}))
);
}
if (s.showEditedMarker && props.isEdited && !props.isDeleted) {
nodes.push(/* @__PURE__ */ React.createElement(MessageMarker, { key: "hc-edited-marker", text: "\u6B64\u6D88\u606F\u5DF2\u7F16\u8F91", at: props.editedAt, edited: true }));
}
if (s.showDeletedMarker && props.isDeleted) {
nodes.push(/* @__PURE__ */ React.createElement(MessageMarker, { key: "hc-deleted-marker", text: "\u6B64\u6D88\u606F\u5DF2\u5220\u9664", at: props.deletedAt }));
}
if (props.isDeleted && props.media && props.media.length > 0) {
nodes.push(
/* @__PURE__ */ React.createElement("div", { className: "hc-deleted-media", key: "hc-deleted-media" }, props.media.map(
(m, i) => m.kind === "file" ? /* @__PURE__ */ React.createElement(
"a",
{
className: "hc-deleted-media__file",
key: i,
href: m.url,
target: "_blank",
rel: "noreferrer"
},
"\u{1F4CE} ",
m.name ?? "\u9644\u4EF6"
) : /* @__PURE__ */ React.createElement(
"img",
{
className: "hc-deleted-media__thumb",
key: i,
src: m.url,
alt: m.name ?? "",
loading: "lazy",
referrerPolicy: "no-referrer"
}
)
))
);
}
return nodes.length ? /* @__PURE__ */ React.createElement(React.Fragment, null, nodes) : null;
}
var DELETE_STYLE_CLASSES = ["tint", "text", "ghost", "strike"];
function syncDeleteStyleClass() {
try {
const root = document.documentElement;
if (!root) return;
for (const s of DELETE_STYLE_CLASSES) root.classList.remove(`hc-mlog-${s}`);
root.classList.add(`hc-mlog-${settings.store.deleteStyle || "tint"}`);
} catch {
}
}
function reportPatches() {
const mine = getSourcePatchReport().filter((p) => p.pluginId === "message-logger");
if (!mine.length) return;
for (const p of mine) {
if (p.applied) {
log13.info(`patch OK \xB7 ${p.label} (${p.hits} hit${p.hits === 1 ? "" : "s"})`);
} else {
log13.warn(`patch MISS \xB7 ${p.label} \u2014 \u672A\u5339\u914D\u5F53\u524D Discord \u6784\u5EFA`);
}
}
const missed = mine.filter((p) => !p.applied);
if (missed.length === 0) {
log13.info("in-chat patches applied \u2014 \u5168\u90E8\u547D\u4E2D");
} else {
log13.warn(
"\u90E8\u5206 in-chat patch \u672A\u5339\u914D\u5F53\u524D Discord \u6784\u5EFA\uFF1A" + missed.map((p) => `"${p.label}"`).join("\u3001") + "\u3002\u5220\u9664\u6D88\u606F\u4ECD\u4F1A\u8BB0\u5F55\u5728\u63D2\u4EF6\u9875\uFF0C\u4F46\u53EF\u80FD\u65E0\u6CD5\u5728\u804A\u5929\u5185\u4FDD\u7559 / \u53D8\u7EA2\u3002"
);
}
const storeMissed = mine.some((p) => p.label === "keep deleted message in store" && !p.applied);
const recordMissed = mine.some((p) => p.label === "declare deleted field on message record" && !p.applied);
if (storeMissed || recordMissed) {
try {
const forms = ["MESSAGE_DELETE:function", "MESSAGE_DELETE(", "MESSAGE_DELETE_BULK"];
const dumps = forms.map((needle) => {
const out = dumpFactorySource(needle, 220);
return out.startsWith("<no loaded factory") || out.startsWith("<webpack") ? "" : `\u3010${needle}\u3011${out}`;
}).filter(Boolean);
const combined = dumps.join(" || ").replace(/\s+/g, " ");
const slice = combined.length > 3800 ? combined.slice(0, 3800) + " \u2026(\u622A\u65AD)" : combined;
log13.warn(
"MESSAGE_DELETE \u5904\u7406\u5668\u771F\u5B9E\u6E90\u7801\u5207\u7247\uFF08\u8865\u4E01\u672A\u547D\u4E2D\uFF0C\u7528\u4E8E\u4FEE\u6B63\uFF0C\u8BF7\u6574\u6BB5\u53D1\u7ED9\u5F00\u53D1\u8005\uFF09\uFF1A" + (slice || "\u672A\u5728\u5DF2\u52A0\u8F7D\u6A21\u5757\u4E2D\u627E\u5230 MESSAGE_DELETE \u5904\u7406\u5668\uFF1B\u8BF7\u5148\u6253\u5F00\u4E00\u4E2A\u9891\u9053\u540E\u518D\u67E5\u770B\u65E5\u5FD7\u3002")
);
} catch (err) {
log13.error("could not dump MESSAGE_DELETE handler shape", err);
}
}
}
var message_logger_default = definePlugin({
id: "message-logger",
name: "\u6D88\u606F\u8BB0\u5F55\u5668",
description: "\u4FDD\u7559\u88AB\u5220\u9664\u7684\u6D88\u606F\u4E0E\u7F16\u8F91\u5386\u53F2\uFF0C\u53EF\u6309\u7528\u6237\u6216\u9891\u9053\u5FFD\u7565\uFF0C\u652F\u6301\u5BFC\u51FA\u3002",
authors: [{ name: "caitemm" }],
category: "utility",
settings,
page: {
title: "\u6D88\u606F\u8BB0\u5F55",
icon: ClockIcon,
component: LogPage
},
/** Diagnostic snapshot for HalcyonAPI.probe() — jump readiness in particular. */
probe() {
const jump = JumpActions;
const router = NavigationRouter;
let scanRouterFound = false;
try {
scanRouterFound = typeof find(
(x) => typeof x?.transitionTo === "function" && typeof x?.__halcyon_probe__ === "undefined"
)?.transitionTo === "function";
} catch {
scanRouterFound = false;
}
return {
jumpActionsFound: jump != null,
jumpToMessageIsFn: typeof jump?.jumpToMessage === "function",
navigationRouterFound: router != null,
transitionToIsFn: typeof router?.transitionTo === "function",
scanRouterFound,
deletedCount: messageLog.getDeleted().length,
settingsHostEmbedded: getSourcePatchReport().some(
(p) => p.pluginId === "halcyon-settings" && p.applied
)
};
},
patches: [
{
// The message store drops records when it handles MESSAGE_DELETE /
// MESSAGE_DELETE_BULK. Instead of letting it, we rebuild the channel
// cache ourselves: kept messages are re-committed flagged `deleted:true`,
// everything else is removed exactly as the original would. Without this
// the row simply vanishes the instant a message is deleted and only
// reappears (red) on reload via the revive path — precisely the
// "刷新才有、实时没有" symptom.
label: "keep deleted message in store",
// Ported VERBATIM from Vencord's MessageLogger "MessageStore" patch.
// The module is selected by the store's registered name — the quoted
// string "MessageStore" the minifier keeps — and the handler is patched
// by a ZERO-WIDTH insertion right after `MESSAGE_DELETE:function(e){`:
// the original body stays byte-for-byte intact and merely becomes
// unreachable after our early `return`. Vencord tracks the current
// Discord build, so this is the shape that actually ships; the earlier
// hand-rolled regexes guessed at method-shorthand / multi-dot shapes this
// client no longer uses, which is exactly why they missed 4×. Vencord's
// `\i` token is expanded here to its definition [A-Za-z_$][\w$]*.
find: '"MessageStore"',
replacement: [
{
// Single delete. $1 = raw action param, $2 = store ref (e.g. `d.A`).
match: /(?<=MESSAGE_DELETE:function\(([A-Za-z_$][\w$]*)\)\{)(?=let.{0,100}?([A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*)\.getOrCreate)/,
replace: "let cache=$2.getOrCreate($1.channelId);cache=$self.handleDelete(cache,$1,!1);$2.commit(cache);return;"
},
{
// Bulk delete.
match: /(?<=MESSAGE_DELETE_BULK:function\(([A-Za-z_$][\w$]*)\)\{)(?=let.{0,100}?([A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*)\.getOrCreate)/,
replace: "let cache=$2.getOrCreate($1.channelId);cache=$self.handleDelete(cache,$1,!0);$2.commit(cache);return;"
}
]
},
{
// Base message row: append our class to the "li" so kept messages tint
// red. The find string is a dev assertion that survives minification.
// The prefix character before `("li",{` can be `)` (a `(0, X.createElement)`
// style call preserved by some minifiers) OR a plain identifier
// (`_jsx("li",` in a jsx-runtime build), so match either — the old
// pattern only accepted `)` and no-op'd on jsx-runtime builds.
label: "tint deleted message row (base)",
find: "Message must not be a thread starter message",
replacement: {
match: /([)\w$\]])\("li",\{(.+?),className:/,
replace: '$1("li",{$2,className:($self.deletedClass(arguments[0])||"")+" "+'
}
},
{
// The message row builds a className. When the record is flagged deleted,
// append our modifier so it renders tinted. The argument list may carry
// nested calls/strings, so parens are matched one level deep instead of
// `[^)]*` (which used to cut mid-expression and produce code that failed
// to compile).
label: "tint deleted message row",
find: "childrenRepliedMessage",
replacement: {
match: /(className:)(\w+\(\)\((?:[^()"']|"[^"]*"|'[^']*'|\([^()]*\))*\))/,
replace: '$1[$2,$self.deletedClass(arguments[0])].filter(Boolean).join(" ")'
}
},
{
// Message content renderer: prepend the persisted edit history above the
// current content, so old versions read top-to-bottom. Located via the
// ".SEND_FAILED," module; the history comes from OUR persisted store, so
// it survives client reloads.
label: "inline edit history",
find: ".SEND_FAILED,",
replacement: {
match: /\]:[\w$]+\.isUnsupported.{0,30}?,children:\[/,
replace: "$&$self.renderEdits(arguments[0]),"
}
},
{
// Force a live re-render when a message is flagged deleted.
//
// This is the fix for "红条只有刷新后才出现". The store patch above keeps a
// deleted message with `deleted: true` (confirmed live by the self-check
// log), but Discord wraps each message row in React.memo with a custom
// comparator that only re-renders when content / state / flags /
// editedTimestamp change — it never looks at `deleted`. So flipping the
// flag updates the store but the row is memoized stale and never repaints;
// the red styling only shows on reload, when the list mounts fresh.
//
// We extend that comparator to also compare `deleted`, so the instant we
// flag a message the row is considered changed and repaints red. The two
// message variables are captured straight from the tail of the comparator
// (`X.editedTimestamp?.toString()===Y.editedTimestamp?.toString()`) so we
// never hardcode minified names. Same module as the edit-history patch
// (".SEND_FAILED,").
label: "re-render on deleted flag",
find: ".SEND_FAILED,",
replacement: {
match: /((\w+)\.editedTimestamp\?\.toString\(\)===(\w+)\.editedTimestamp\?\.toString\(\))/,
replace: "$1&&$2.deleted===$3.deleted"
}
},
{
// THE fix for "红条只有刷新后才出现". Discord's Message is an Immutable
// Record with a FIXED field schema. `deleted` is not one of those fields,
// so `m.set("deleted",true)` stores a value that is readable (the live
// self-check sees deleted===true) but is invisible to the record's
// structural equality — and Discord's message list decides whether to
// repaint a row via that equality. Old record and new record compare
// "equal" on the schema fields, so the row is never repainted live; only
// a reload (fresh mount) shows the red styling.
//
// Declaring `deleted` (and editHistory / firstEditTimestamp) as real
// fields on the record class means `.set("deleted",true)` now yields a
// record that is genuinely not-equal to the original, so the list
// repaints the instant we flag it. Ported verbatim from Vencord's
// "Message domain model" patch, which is what makes deletes show live
// there. Runs in the record constructor, located by `}addReaction(`.
label: "declare deleted field on message record",
find: /\}addReaction\(|addReaction\([\w$]+\)\{/,
replacement: {
match: /this\.customRenderedContent=(\w+)\.customRenderedContent,/,
replace: "this.customRenderedContent=$1.customRenderedContent,this.deleted=$1.deleted||!1,this.editHistory=$1.editHistory||[],this.firstEditTimestamp=$1.firstEditTimestamp||this.editedTimestamp||this.timestamp,"
}
},
{
// Keep `deleted` / editHistory / firstEditTimestamp alive when Discord
// rebuilds a message record on MESSAGE_UPDATE (edits, reactions, embed
// unfurls). Without this, any post-delete update to the same message
// re-derives the record from the server payload and silently drops our
// flag, so a deleted message that then gets an embed/reaction update
// would lose its red row. Ported from Vencord's "updated message
// transformer" patch, located by ".PREMIUM_REFERRAL&&(".
label: "carry deleted flag through message updates",
find: /\.PREMIUM_REFERRAL\s*&&\s*\(/,
replacement: {
match: /(?<=null!=[\w$]+\.edited_timestamp\)return )[\w$]+\([\w$]+,\{reactions:([\w$]+)\.reactions[\s\S]{0,60}?\}\)/,
replace: "Object.assign($&,{deleted:$1.deleted,editHistory:$1.editHistory,firstEditTimestamp:$1.firstEditTimestamp})"
}
}
],
start() {
messageLog.load();
messageLog.setRetention(settings.store.retention);
unsubscribeRetention = settings.subscribe("retention", (next) => messageLog.setRetention(next));
syncDeleteStyleClass();
unsubscribeDeleteStyle = settings.subscribe("deleteStyle", syncDeleteStyleClass);
unpatchDispatch = attachRecorderEverywhere();
startDomTinter();
startToolbarButton();
setTimeout(reportPatches, 4e3);
setTimeout(() => {
if (actionsSeen > 0) {
log13.info(`recorder pulse OK \u2014 ${actionsSeen} message action(s) observed so far`);
} else {
log13.error(
"recorder pulse FAILED \u2014 no message actions observed in 30s. The dispatcher hooks are not receiving events on this build. \u8BF7\u628A\u65E5\u5FD7\u9875\u91CC recorder on dispatcher \u5F00\u5934\u7684\u51E0\u884C\u53D1\u7ED9\u5F00\u53D1\u8005\u3002"
);
}
}, 3e4);
},
stop() {
unpatchDispatch?.();
unpatchDispatch = void 0;
unsubscribeRetention?.();
unsubscribeRetention = void 0;
unsubscribeDeleteStyle?.();
unsubscribeDeleteStyle = void 0;
stopDomTinter();
stopToolbarButton();
try {
for (const s of DELETE_STYLE_CLASSES) document.documentElement?.classList.remove(`hc-mlog-${s}`);
} catch {
}
messageLog.flush();
log13.info("stopped");
},
// --- methods the source patches call through `$self` --------------------
/**
* Replacement body for the MessageStore's MESSAGE_DELETE(_BULK) handlers.
* Receives the channel's immutable message cache; returns it with kept
* messages flagged `deleted: true` (so the row patch tints them) and
* everything else removed, exactly as the original handler would have.
* Any surprise falls back to the original handler by returning the cache
* unchanged only when nothing was touched — a throw here must never take
* the store down, so the whole body is guarded.
*/
handleDelete(cache2, action, isBulk) {
try {
if (cache2 == null) return cache2;
if (!isBulk && typeof cache2.has === "function" && !cache2.has(action.id)) return cache2;
const keepInChat = settings.store.keepDeletedInChat;
const EPHEMERAL = 64;
const mutate = (id) => {
const msg = typeof cache2.get === "function" ? cache2.get(id) : void 0;
if (!msg) return;
const keep = keepInChat && !action.mlDeleted && (msg.flags & EPHEMERAL) !== EPHEMERAL && !isIgnored(String(action.channelId ?? action.channel_id ?? msg.channel_id ?? ""), msg.author ?? {});
if (!keep) {
cache2 = cache2.remove(id);
} else {
cache2 = cache2.update(id, (m) => m.set("deleted", true));
}
};
if (isBulk) {
for (const id of action.ids ?? []) mutate(id);
} else {
mutate(action.id);
}
} catch (err) {
log13.error("handleDelete failed; messages removed normally", err);
}
return cache2;
},
/**
* Extra classNames for a message row whose message is deleted. Keyed off the
* PERSISTED record (not just the in-memory `deleted` flag), so rows stay
* marked across reloads; the flag remains a fast path for freshly deleted
* ones. The style modifier comes from settings, so users pick the look.
* Patched call sites differ in what they pass: the base row patch hands us
* the component props (message under `.message`), the legacy patch the
* message itself — accept either.
*/
deletedClass(propsOrMessage) {
try {
const m = propsOrMessage?.message ?? propsOrMessage;
if (!m) return "";
const channelId = m.channel_id ?? m.channelId;
const isDeleted = m.deleted === true || channelId && m.id && messageLog.isDeleted(String(channelId), String(m.id));
if (!isDeleted) return "";
return "hc-deleted";
} catch {
return "";
}
},
/**
* Called from the content-renderer patch with the component's props. Renders
* (a) the persisted edit history above the content and (b) a "deleted at"
* marker line beneath it, both from the plugin's own store, so both survive
* reloads. Runs inside Discord's message renderer and must never break it —
* everything is guarded and returns null on any surprise.
*/
renderEdits(props) {
try {
const message = props?.message;
const id = message?.id;
const channelId = message?.channel_id ?? message?.channelId;
if (!id || !channelId) return null;
if (isIgnored(String(channelId), message?.author)) return null;
const entry = messageLog.getEdited().find((e) => e.id === String(id) && e.channelId === String(channelId));
const record2 = messageLog.findDeleted(String(channelId), String(id));
const hasHistory = Boolean(entry && entry.history.length > 0);
const isDeleted = Boolean(record2) || message?.deleted === true;
const editedTs = message?.edited_timestamp ?? message?.editedTimestamp;
const isEdited = editedTs != null || hasHistory;
const editedAt = editedTs != null ? toMillis(editedTs) : entry?.updatedAt;
if (!hasHistory && !isDeleted && !isEdited) return null;
return /* @__PURE__ */ React.createElement(
MessageExtras,
{
history: entry?.history,
deletedAt: record2?.deletedAt,
editedAt,
isDeleted,
isEdited,
media: isDeleted ? collectDeletedMedia(record2?.attachmentsRich, record2?.embeds) : void 0
}
);
} catch {
return null;
}
}
});
// src/plugins/show-username/index.tsx
var log14 = logger("show-username");
var settings2 = defineSettings({
mode: {
type: "select",
default: "nick-user",
label: "\u663E\u793A\u65B9\u5F0F",
description: "\u6635\u79F0\u4E0E\u7528\u6237\u540D\u7684\u6392\u5217\u3002",
options: [
{ value: "nick-user", label: "\u6635\u79F0\u5728\u524D\uFF0C\u7528\u6237\u540D\u5728\u540E" },
{ value: "user-nick", label: "\u7528\u6237\u540D\u5728\u524D\uFF0C\u6635\u79F0\u5728\u540E" },
{ value: "user-only", label: "\u53EA\u663E\u793A\u7528\u6237\u540D" }
]
},
style: {
type: "select",
default: "muted",
label: "\u7528\u6237\u540D\u6837\u5F0F",
description: "\u9644\u52A0\u7684\u7528\u6237\u540D\u90E8\u5206\u7684\u89C6\u89C9\u6837\u5F0F\u3002",
options: [
{ value: "muted", label: "\u7070\u8272\u5C0F\u5B57" },
{ value: "pill", label: "\u5706\u89D2\u80F6\u56CA" },
{ value: "at", label: "@ \u524D\u7F00" },
{ value: "paren", label: "\u62EC\u53F7\u5305\u88F9" }
]
},
hideWhenSame: {
type: "boolean",
default: true,
label: "\u6635\u79F0\u76F8\u540C\u65F6\u9690\u85CF",
description: "\u6635\u79F0\u4E0E\u7528\u6237\u540D\u4E00\u81F4\u65F6\u4E0D\u91CD\u590D\u663E\u793A\u3002"
},
inReplies: {
type: "boolean",
default: false,
label: "\u56DE\u590D\u9884\u89C8\u4E2D\u4E5F\u663E\u793A",
description: "\u5728\u56DE\u590D\u5F15\u7528\u7684\u5C0F\u5B57\u6761\u4E2D\u4E5F\u9644\u52A0\u7528\u6237\u540D\u3002"
}
});
function Username(props) {
const { original } = props;
const s = settings2.store;
const user = original.userOverride ?? original.message?.author;
const username = user?.username;
const nick = original.author?.nick ?? user?.globalName ?? username ?? "";
const prefix = original.withMentionPrefix ? "@" : "";
try {
if (!username) return /* @__PURE__ */ React.createElement(React.Fragment, null, prefix, nick);
if (original.isRepliedMessage && !s.inReplies) return /* @__PURE__ */ React.createElement(React.Fragment, null, prefix, nick);
if (s.hideWhenSame && username.toLowerCase() === nick.toLowerCase()) return /* @__PURE__ */ React.createElement(React.Fragment, null, prefix, nick);
const suffixClass = `hc-username hc-username--${s.style || "muted"}`;
const decorated = s.style === "at" ? `@${username}` : s.style === "paren" ? `\uFF08${username}\uFF09` : username;
if (s.mode === "user-only") {
return /* @__PURE__ */ React.createElement(React.Fragment, null, prefix, username);
}
if (s.mode === "user-nick") {
return /* @__PURE__ */ React.createElement(React.Fragment, null, prefix, username, " ", /* @__PURE__ */ React.createElement("span", { className: suffixClass }, nick));
}
return /* @__PURE__ */ React.createElement(React.Fragment, null, prefix, nick, " ", /* @__PURE__ */ React.createElement("span", { className: suffixClass }, decorated));
} catch (err) {
log14.error("username render failed; falling back to the nick", err);
return /* @__PURE__ */ React.createElement(React.Fragment, null, prefix, nick);
}
}
var show_username_default = definePlugin({
id: "show-username",
name: "\u663E\u793A\u7528\u6237\u540D",
description: "\u5728\u6635\u79F0\u65C1\u8FB9\u663E\u793A\u8D26\u53F7\u7528\u6237\u540D\uFF0C\u9632\u6B62\u6539\u540D\u5192\u5145\uFF0C\u652F\u6301\u591A\u79CD\u6837\u5F0F\u3002",
authors: [{ name: "caitemm" }],
category: "appearance",
settings: settings2,
patches: [
{
// The message-header module (find string survives minification). The
// username hook renders `children: <ternary>`; capturing that whole
// expression is fragile (nested commas/parens — a truncated capture
// produced unbalanced code), so instead our render becomes the new
// `children` and the original expression is parked, syntactically
// intact, under a dummy `_hcOld` prop.
label: "message header username",
find: '="SYSTEM_TAG"',
replacement: {
match: /(?<=onContextMenu:[\w$]+,children:)([\w$]+)\?(?=.{0,100}?user[Nn]ame:)/,
replace: "$self.renderUsername(arguments[0]),_hcOld:$1?"
}
}
],
start() {
log14.info("appending usernames to message headers");
},
stop() {
},
/** Called from the patch with the header component's props. */
renderUsername(props) {
try {
return /* @__PURE__ */ React.createElement(Username, { original: props });
} catch {
return props?.author?.nick ?? null;
}
}
});
// src/plugins/guild-monitor/settings.ts
var settings3 = defineSettings({
// Toggled from the plugin page (with the full risk note), not the generic
// form, so it's hidden here — but persisted through the store like any value.
acknowledgedRisk: {
type: "boolean",
default: false,
label: "\u6211\u5DF2\u4E86\u89E3\u5C01\u53F7\u98CE\u9669",
description: "\u4E3B\u52A8\u8BA2\u9605\u9891\u9053\u5C5E\u4E8E\u81EA\u52A8\u5316\u884C\u4E3A\uFF0C\u53EF\u80FD\u8FDD\u53CD Discord \u670D\u52A1\u6761\u6B3E\u5E76\u5BFC\u81F4\u8D26\u53F7\u88AB\u5C01\u3002\u4EC5\u5728\u4F60\u5B8C\u5168\u7406\u89E3\u5E76\u81EA\u613F\u627F\u62C5\u98CE\u9669\u65F6\u5F00\u542F\u3002",
hidden: true
},
selectedGuilds: {
type: "string-list",
default: [],
label: "\u76D1\u63A7\u7684\u670D\u52A1\u5668",
description: "\u6309\u670D\u52A1\u5668 ID \u76D1\u63A7\u3002\u5EFA\u8BAE\u4ECE\u4E0B\u65B9\u7684\u670D\u52A1\u5668\u5217\u8868\u52FE\u9009\uFF0C\u800C\u4E0D\u662F\u624B\u586B\u3002",
itemPlaceholder: "\u670D\u52A1\u5668 ID",
hidden: true
}
});
// src/plugins/guild-monitor/subscribe.ts
var log15 = logger("guild-monitor");
var REFRESH_MS = 5 * 60 * 1e3;
var timer2;
var getGuildIds = () => [];
function textChannelIds(guildId) {
try {
const grouped = GuildChannelStore.getChannels(guildId);
if (!grouped || typeof grouped !== "object") return [];
const ids = /* @__PURE__ */ new Set();
for (const value of Object.values(grouped)) {
if (!Array.isArray(value)) continue;
for (const item of value) {
const ch = item?.channel ?? item;
const id = ch?.id;
if (id != null && (ch?.type === 0 || ch?.type === 5)) ids.add(String(id));
}
}
return [...ids];
} catch (err) {
log15.debug(`could not read channels for guild ${guildId}`, err);
return [];
}
}
function subscribeGuild(guildId) {
const api = GuildSubscriptions;
if (!api) return;
try {
if (typeof api.subscribeToChannel === "function") {
for (const channelId of textChannelIds(guildId)) {
api.subscribeToChannel(guildId, channelId);
}
return;
}
if (typeof api.subscribeToGuild === "function") {
api.subscribeToGuild(guildId);
}
} catch (err) {
log15.warn(`subscribe failed for guild ${guildId}`, err);
}
}
function isSubscriptionSupported() {
const api = GuildSubscriptions;
return Boolean(api && (typeof api.subscribeToChannel === "function" || typeof api.subscribeToGuild === "function"));
}
function pass() {
const ids = getGuildIds();
if (!ids.length) return;
for (const id of ids) subscribeGuild(id);
log15.debug(`refreshed subscriptions for ${ids.length} guild(s)`);
}
function startSubscribing(resolver) {
getGuildIds = resolver;
stopSubscribing();
if (!isSubscriptionSupported()) {
log15.warn("this Discord build exposes no guild-subscription action; monitoring is inactive");
return;
}
pass();
timer2 = setInterval(pass, REFRESH_MS);
}
function refreshNow() {
if (timer2) pass();
}
function stopSubscribing() {
if (timer2) {
clearInterval(timer2);
timer2 = void 0;
}
}
// src/plugins/guild-monitor/ui/MonitorPage.tsx
function readGuilds() {
try {
const store = findStore("GuildStore") ?? GuildStore;
const map = store?.getGuilds?.() ?? {};
return Object.values(map).map((g2) => ({ id: String(g2?.id ?? ""), name: String(g2?.name ?? g2?.id ?? "\u672A\u77E5\u670D\u52A1\u5668") })).filter((g2) => g2.id).sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
} catch {
return [];
}
}
function MonitorPage() {
const [guilds, setGuilds] = useState(() => readGuilds());
const [selected, setSelected] = useState(() => [...settings3.store.selectedGuilds]);
const [acknowledged, setAcknowledged] = useState(() => settings3.store.acknowledgedRisk === true);
const supported = isSubscriptionSupported();
useEffect(() => {
if (guilds.length === 0) {
const t = setTimeout(() => setGuilds(readGuilds()), 400);
return () => clearTimeout(t);
}
}, [guilds.length]);
const persist = (ids) => {
setSelected(ids);
settings3.store.selectedGuilds = ids;
refreshNow();
};
const toggleGuild = (id) => {
persist(selected.includes(id) ? selected.filter((g2) => g2 !== id) : [...selected, id]);
};
const setAck = (on) => {
setAcknowledged(on);
settings3.store.acknowledgedRisk = on;
if (!on) persist([]);
};
return /* @__PURE__ */ React.createElement("div", { className: "hc-stack" }, /* @__PURE__ */ React.createElement("div", { className: "hc-inline-note hc-inline-note--danger" }, /* @__PURE__ */ React.createElement(WarningIcon, { size: 18 }), /* @__PURE__ */ React.createElement("span", null, "\u4E3B\u52A8\u76D1\u63A7\u4F1A\u8BA2\u9605\u4F60\u5C1A\u672A\u6253\u5F00\u7684\u9891\u9053\uFF0C\u5C5E\u4E8E\u81EA\u52A8\u5316\u884C\u4E3A\uFF0C\u53EF\u80FD\u8FDD\u53CD Discord \u670D\u52A1\u6761\u6B3E\u5E76\u5BFC\u81F4", /* @__PURE__ */ React.createElement("b", null, "\u8D26\u53F7\u88AB\u5C01\u7981"), "\u3002\u8BF7\u81EA\u884C\u627F\u62C5\u98CE\u9669\u3002")), /* @__PURE__ */ React.createElement("div", { className: "hc-section" }, /* @__PURE__ */ React.createElement("div", { className: "hc-section__body" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell hc-cell--row" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, "\u542F\u7528\u4E3B\u52A8\u76D1\u63A7"), /* @__PURE__ */ React.createElement("div", { className: "hc-cell__desc" }, "\u5F00\u542F\u540E\u624D\u80FD\u52FE\u9009\u4E0B\u65B9\u7684\u670D\u52A1\u5668\u3002")), /* @__PURE__ */ React.createElement(Toggle, { checked: acknowledged, onChange: setAck, "aria-label": "\u542F\u7528\u4E3B\u52A8\u76D1\u63A7" })))), !supported && /* @__PURE__ */ React.createElement("div", { className: "hc-inline-note" }, /* @__PURE__ */ React.createElement(WarningIcon, { size: 18 }), /* @__PURE__ */ React.createElement("span", null, "\u5F53\u524D Discord \u7248\u672C\u672A\u66B4\u9732\u53EF\u7528\u7684\u8BA2\u9605\u63A5\u53E3\uFF0C\u76D1\u63A7\u6682\u65F6\u65E0\u6CD5\u751F\u6548\u3002")), /* @__PURE__ */ React.createElement("div", { className: "hc-section" }, /* @__PURE__ */ React.createElement("div", { className: "hc-section__title", style: { display: "flex", justifyContent: "space-between" } }, /* @__PURE__ */ React.createElement("span", null, "\u670D\u52A1\u5668\uFF08", guilds.length, "\uFF09"), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-tab",
onClick: () => setGuilds(readGuilds()),
style: { height: 20, padding: "0 8px", textTransform: "none" }
},
/* @__PURE__ */ React.createElement(RefreshIcon, { size: 12 }),
" \u5237\u65B0"
)), guilds.length === 0 ? /* @__PURE__ */ React.createElement(
EmptyState,
{
icon: /* @__PURE__ */ React.createElement(ServerIcon, { size: 48 }),
title: "\u6CA1\u6709\u8BFB\u5230\u670D\u52A1\u5668",
subtitle: "\u7B49 Discord \u52A0\u8F7D\u5B8C\u6210\u540E\u70B9\u4E0A\u9762\u7684\u5237\u65B0\uFF0C\u6216\u7A0D\u540E\u518D\u6765\u3002"
}
) : /* @__PURE__ */ React.createElement("div", { className: "hc-section__body", style: { opacity: acknowledged ? 1 : 0.5, pointerEvents: acknowledged ? "auto" : "none" } }, guilds.map((g2) => /* @__PURE__ */ React.createElement("div", { className: "hc-cell hc-cell--row", key: g2.id }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, g2.name), /* @__PURE__ */ React.createElement("div", { className: "hc-cell__desc" }, g2.id)), /* @__PURE__ */ React.createElement(
Toggle,
{
checked: selected.includes(g2.id),
onChange: () => toggleGuild(g2.id),
"aria-label": `\u76D1\u63A7 ${g2.name}`
}
))))), selected.length > 0 && /* @__PURE__ */ React.createElement("div", { className: "hc-savebar" }, /* @__PURE__ */ React.createElement("span", { className: "hc-savebar__label" }, "\u6B63\u5728\u76D1\u63A7 ", selected.length, " \u4E2A\u670D\u52A1\u5668"), /* @__PURE__ */ React.createElement("div", { className: "hc-savebar__actions" }, /* @__PURE__ */ React.createElement(Button, { size: "sm", variant: "destructive", onClick: () => persist([]) }, "\u5168\u90E8\u53D6\u6D88"))));
}
// src/plugins/guild-monitor/index.tsx
var log16 = logger("guild-monitor");
function activeGuildIds() {
if (settings3.store.acknowledgedRisk !== true) return [];
const ids = settings3.store.selectedGuilds;
return Array.isArray(ids) ? ids : [];
}
var guild_monitor_default = definePlugin({
id: "guild-monitor",
name: "\u670D\u52A1\u5668\u76D1\u63A7",
description: "\u4E3B\u52A8\u8BA2\u9605\u9009\u5B9A\u670D\u52A1\u5668\u7684\u9891\u9053\uFF0C\u6355\u6349\u672A\u6253\u5F00\u9891\u9053\u91CC\u7684\u6D88\u606F\uFF08\u6709\u5C01\u53F7\u98CE\u9669\uFF0C\u9ED8\u8BA4\u5173\u95ED\uFF09\u3002",
authors: [{ name: "caitemm" }],
category: "privacy",
settings: settings3,
page: {
title: "\u76D1\u63A7",
icon: BroadcastIcon,
component: MonitorPage
},
start() {
startSubscribing(activeGuildIds);
const n = activeGuildIds().length;
if (n > 0) log16.info(`monitoring ${n} guild(s)`);
},
stop() {
stopSubscribing();
}
});
// src/plugins/message-cleaner/settings.ts
var settings4 = defineSettings({
order: {
group: "\u9ED8\u8BA4\u53C2\u6570",
type: "select",
default: "desc",
label: "\u6E05\u7406\u65B9\u5411",
description: "\u53D7\u6761\u6570\u9650\u5236\u65F6\uFF0C\u4F18\u5148\u4ECE\u54EA\u4E00\u7AEF\u5F00\u59CB\u5220\u3002",
options: [
{ value: "desc", label: "\u4ECE\u65B0\u5230\u8001" },
{ value: "asc", label: "\u4ECE\u8001\u5230\u65B0" }
]
},
limit: {
group: "\u9ED8\u8BA4\u53C2\u6570",
type: "number",
default: 100,
label: "\u6700\u591A\u5904\u7406\u6761\u6570",
description: "\u5355\u6B21\u9884\u89C8 / \u5220\u9664\u7684\u4E0A\u9650\u3002",
min: 1,
max: 5e3,
step: 50
},
delayMs: {
group: "\u9ED8\u8BA4\u53C2\u6570",
type: "number",
default: 1600,
label: "\u5220\u9664\u95F4\u9694\uFF08\u6BEB\u79D2\uFF09",
description: "\u4E24\u6B21\u5220\u9664\u4E4B\u95F4\u7684\u7B49\u5F85\uFF0C\u592A\u5FEB\u4F1A\u89E6\u53D1\u9650\u901F\uFF0C\u5EFA\u8BAE\u4E0D\u4F4E\u4E8E 1000\u3002",
min: 300,
max: 3e4,
step: 100
},
confirmBeforeDelete: {
group: "\u9ED8\u8BA4\u53C2\u6570",
type: "boolean",
default: true,
label: "\u5220\u9664\u524D\u4E8C\u6B21\u786E\u8BA4",
description: "\u70B9\u300C\u5220\u9664\u300D\u540E\u5F39\u51FA\u786E\u8BA4\u6846\uFF0C\u907F\u514D\u8BEF\u5220\u3002"
}
});
// src/plugins/message-cleaner/cleaner.ts
var log17 = logger("message-cleaner");
var API_BASE = "https://discord.com/api/v10";
var skipList = /* @__PURE__ */ new Set();
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
var EPOCH = 1420070400000n;
var tsToSf = (d) => String(BigInt(d.getTime()) - EPOCH << 22n);
function extractToken() {
try {
const chunks = window.webpackChunkdiscord_app;
if (Array.isArray(chunks)) {
let tok = null;
chunks.push([[Symbol()], {}, (req) => {
for (const id of Object.keys(req.m || {})) {
try {
for (const m of [req(id), req(id)?.default]) {
if (m && typeof m.getToken === "function") {
const t = m.getToken();
if (t && t.length > 20) {
tok = t;
return;
}
}
}
} catch {
}
}
}]);
if (tok) return tok;
}
} catch {
}
try {
const t = window.localStorage.getItem("token");
if (t) return t.replace(/^"|"$/g, "");
} catch {
}
return null;
}
async function apiFetch(token, path, opts = {}, attempt = 0) {
let res;
try {
res = await fetch(API_BASE + path, {
...opts,
headers: {
Authorization: token,
"Content-Type": "application/json",
...opts.headers || {}
}
});
} catch (e) {
if (attempt < 5) {
await sleep(3e3);
return apiFetch(token, path, opts, attempt + 1);
}
throw new Error(`\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25: ${e.message}`);
}
if (res.status === 429) {
const j = await res.json().catch(() => ({}));
const wait = j.retry_after ? Math.ceil(Number(j.retry_after) * 1e3) : Math.pow(2, attempt) * 1e3;
if (attempt < 5) {
await sleep(wait + 500);
return apiFetch(token, path, opts, attempt + 1);
}
throw new Error("\u89E6\u53D1\u9650\u901F\u4E14\u91CD\u8BD5\u6B21\u6570\u8017\u5C3D\u3002");
}
if (!res.ok) {
const b = await res.text().catch(() => "");
throw new Error(`API ${res.status}: ${b.slice(0, 120)}`);
}
return res.status === 204 ? null : res.json();
}
async function fetchUserId(token) {
const me = await apiFetch(token, "/users/@me");
if (!me?.id) throw new Error("\u65E0\u6CD5\u901A\u8FC7 Token \u83B7\u53D6\u8D26\u53F7\u4FE1\u606F\uFF0C\u8BF7\u68C0\u67E5 Token \u662F\u5426\u6709\u6548\u3002");
return String(me.id);
}
function currentTarget() {
try {
const m = location.pathname.match(/\/channels\/(\d{15,25}|@me)\/(\d{15,25})/);
if (!m) return null;
return { guildId: m[1], channelId: m[2], serverWide: false };
} catch {
return null;
}
}
async function fetchGuilds(token) {
const guilds = await apiFetch(token, "/users/@me/guilds");
if (!Array.isArray(guilds)) return [];
return guilds.map((g2) => ({ id: String(g2.id), name: g2.name ?? "\u672A\u77E5", icon: g2.icon ?? null }));
}
async function fetchChannels(token, guildId) {
if (guildId === "@me") {
const dms = await apiFetch(token, "/users/@me/channels");
if (!Array.isArray(dms)) return [];
return dms.map((c) => {
const name = c.name || (Array.isArray(c.recipients) ? c.recipients.map((r) => r.global_name || r.username).join("\u3001") : "") || "\u672A\u77E5\u79C1\u804A";
return { id: String(c.id), name, type: c.type ?? 1 };
});
}
const channels = await apiFetch(token, `/guilds/${guildId}/channels`);
if (!Array.isArray(channels)) return [];
return channels.filter((c) => c.type !== 4).map((c) => ({ id: String(c.id), name: c.name ?? "\u672A\u77E5", type: c.type ?? 0 }));
}
async function collect(token, opts, meId, onProgress, ctrl) {
const out = [];
if (opts.serverWide && opts.guildId && opts.guildId !== "@me") {
let offset = 0;
while (out.length < opts.limit) {
if (ctrl.stopped) break;
onProgress("\u5168\u670D\u68C0\u7D22\u4E2D", `\u5DF2\u627E\u5230 ${out.length} \u6761\uFF08\u641C\u7D22\u63A5\u53E3\u8F83\u6162\uFF0C\u8BF7\u7A0D\u5019\uFF09`);
const params = new URLSearchParams({
author_id: meId,
offset: String(offset),
include_nsfw: "true",
sort_order: opts.order === "asc" ? "asc" : "desc"
});
if (opts.after) params.set("min_id", tsToSf(opts.after));
if (opts.before) params.set("max_id", tsToSf(opts.before));
let res;
try {
res = await apiFetch(token, `/guilds/${opts.guildId}/messages/search?${params}`);
} catch (e) {
throw new Error(`\u5168\u670D\u68C0\u7D22\u5931\u8D25\uFF1A${e.message}`);
}
if (res?.message === "Indexing") {
onProgress("\u5EFA\u7ACB\u7D22\u5F15\u4E2D", "Discord \u6B63\u5728\u5EFA\u7ACB\u5168\u670D\u7D22\u5F15\uFF0C10 \u79D2\u540E\u81EA\u52A8\u91CD\u8BD5\u2026");
await sleep(1e4);
continue;
}
if (!res?.messages || res.messages.length === 0) break;
for (const group of res.messages) {
const m = group.find((x) => x?.hit) ?? group.find((x) => x?.author?.id === meId) ?? group[0];
if (!m || m.author?.id !== meId || skipList.has(m.id)) continue;
out.push({ id: m.id, channelId: m.channel_id, content: m.content ?? "", timestamp: m.timestamp });
if (out.length >= opts.limit) break;
}
if (res.messages.length < 25) break;
offset += res.messages.length;
await sleep(1200);
}
return out;
}
if (!opts.channelId) throw new Error("\u8BF7\u586B\u5199\u9891\u9053 ID\uFF0C\u6216\u5F00\u542F\u300C\u5168\u670D\u626B\u63CF\u300D\u5E76\u586B\u5199\u670D\u52A1\u5668 ID\u3002");
let boundary = null;
if (opts.order === "desc") {
boundary = opts.before ? tsToSf(opts.before) : null;
} else {
boundary = opts.after ? tsToSf(opts.after) : "0";
}
while (out.length < opts.limit) {
if (ctrl.stopped) break;
const params = new URLSearchParams({ limit: "100" });
if (boundary) params.set(opts.order === "desc" ? "before" : "after", boundary);
let batch;
try {
batch = await apiFetch(token, `/channels/${opts.channelId}/messages?${params}`);
} catch (e) {
throw new Error(`\u8BFB\u53D6\u9891\u9053\u6D88\u606F\u5931\u8D25\uFF1A${e.message}`);
}
if (!Array.isArray(batch) || batch.length === 0) break;
for (const m of batch) {
const t = new Date(m.timestamp);
if (opts.order === "desc" && opts.after && t < opts.after) {
return out;
}
if (opts.order === "asc" && opts.before && t > opts.before) {
return out;
}
const inRange = (!opts.after || t >= opts.after) && (!opts.before || t <= opts.before);
if (m.author?.id === meId && inRange && !skipList.has(m.id)) {
out.push({ id: m.id, channelId: m.channel_id ?? opts.channelId, content: m.content ?? "", timestamp: m.timestamp });
if (out.length >= opts.limit) break;
}
}
boundary = batch[batch.length - 1].id;
onProgress("\u626B\u63CF\u4E2D", `\u5DF2\u627E\u5230 ${out.length} \u6761`);
await sleep(150);
}
return out;
}
async function remove(token, messages, opts, onProgress, ctrl) {
let deleted = 0;
let skipped = 0;
for (const m of messages) {
if (ctrl.stopped) break;
const t0 = Date.now();
try {
await apiFetch(token, `/channels/${m.channelId || opts.channelId}/messages/${m.id}`, { method: "DELETE" });
deleted++;
} catch (e) {
skipped++;
if (!String(e?.message ?? "").includes("404")) skipList.add(m.id);
log17.warn(`skip ${m.id}: ${e?.message ?? e}`);
}
onProgress("\u5220\u9664\u4E2D", `\u5DF2\u5220\u9664 ${deleted} / ${messages.length}${skipped ? `\uFF08\u8DF3\u8FC7 ${skipped}\uFF09` : ""}`);
const elapsed = Date.now() - t0;
if (elapsed < opts.delayMs) await sleep(opts.delayMs - elapsed);
}
return { deleted, skipped };
}
async function count(token, target, meId) {
let url;
const params = new URLSearchParams({ author_id: meId, include_nsfw: "true" });
if (target.serverWide && target.guildId && target.guildId !== "@me") {
url = `/guilds/${target.guildId}/messages/search?${params}`;
} else if (target.channelId) {
url = `/channels/${target.channelId}/messages/search?${params}`;
} else if (target.guildId && target.guildId !== "@me") {
url = `/guilds/${target.guildId}/messages/search?${params}`;
} else {
throw new Error("\u8BF7\u586B\u5199\u670D\u52A1\u5668 ID \u6216\u9891\u9053 ID\u3002");
}
const res = await apiFetch(token, url);
if (res?.message === "Indexing") return { total: 0, indexing: true };
return { total: res?.total_results ?? 0, indexing: false };
}
// src/plugins/message-cleaner/ui/CleanerPage.tsx
var log18 = logger("message-cleaner");
function formatTs(ts) {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function CleanerPage() {
const [token, setToken] = useState("");
const [guildId, setGuildId] = useState("");
const [channelId, setChannelId] = useState("");
const [serverWide, setServerWide] = useState(false);
const [afterStr, setAfterStr] = useState("");
const [beforeStr, setBeforeStr] = useState("");
const [order, setOrder] = useState(settings4.store.order);
const [disclaimer, setDisclaimer] = useState(false);
const [mode, setMode] = useState("idle");
const [previewed, setPreviewed] = useState([]);
const [state, setState] = useState("\u5F85\u673A");
const [detail, setDetail] = useState("\u5148\u83B7\u53D6 Token\uFF0C\u9009\u597D\u8303\u56F4\u5E76\u9884\u89C8\uFF0C\u786E\u8BA4\u540E\u518D\u5220\u9664\u3002");
const [statCount, setStatCount] = useState(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [pickerGuilds, setPickerGuilds] = useState([]);
const [pickerChannels, setPickerChannels] = useState([]);
const [pickerLevel, setPickerLevel] = useState("guilds");
const [pickerGuildName, setPickerGuildName] = useState("");
const [pickerLoading, setPickerLoading] = useState(false);
const [pickerError, setPickerError] = useState("");
const ctrlRef = useRef({ stopped: false });
const running = mode !== "idle";
useEffect(() => {
const tok = extractToken();
if (tok) {
setToken(tok);
setState("\u5DF2\u83B7\u53D6 Token");
setDetail("\u53EF\u70B9\u51FB\u300C\u5217\u8868\u300D\u9009\u62E9\u9891\u9053\uFF0C\u6216\u624B\u52A8\u586B\u5199 ID\u3002");
}
}, []);
const progress = (s, d) => {
setState(s);
setDetail(d);
};
const requireToken = () => {
const t = token.trim();
if (!t) throw new Error("\u8BF7\u5148\u83B7\u53D6\u6216\u586B\u5165 Token\u3002");
return t;
};
const buildOptions = () => ({
guildId: guildId.trim(),
channelId: serverWide ? "" : channelId.trim(),
serverWide,
order,
limit: settings4.store.limit,
delayMs: settings4.store.delayMs,
after: afterStr ? new Date(afterStr) : null,
before: beforeStr ? new Date(beforeStr) : null
});
const onAutoToken = () => {
const tok = extractToken();
if (tok) {
setToken(tok);
progress("Token \u5DF2\u83B7\u53D6", "\u53EF\u70B9\u51FB\u300C\u5217\u8868\u300D\u9009\u62E9\u9891\u9053\u3002");
} else progress("\u83B7\u53D6\u5931\u8D25", "\u8BF7\u624B\u52A8\u7C98\u8D34 Token\u3002");
};
const useCurrent = () => {
const target = currentTarget();
if (!target) {
progress("\u65E0\u6CD5\u8BFB\u53D6", "\u5F53\u524D\u4E0D\u5728\u67D0\u4E2A\u9891\u9053/\u79C1\u4FE1\u9875\u9762\u3002");
return;
}
setGuildId(target.guildId);
setChannelId(target.channelId);
setServerWide(false);
progress("\u5DF2\u586B\u5165\u5F53\u524D\u9891\u9053", `\u670D\u52A1\u5668 ${target.guildId} \xB7 \u9891\u9053 ${target.channelId}`);
};
const openPicker = async () => {
let tok;
try {
tok = requireToken();
} catch (e) {
progress("\u9700\u8981 Token", e.message);
return;
}
setPickerOpen(true);
setPickerLevel("guilds");
setPickerChannels([]);
setPickerError("");
setPickerLoading(true);
try {
const guilds = await fetchGuilds(tok);
setPickerGuilds([{ id: "@me", name: "\u79C1\u4FE1\u4E0E\u7FA4\u804A (DMs)", icon: null }, ...guilds]);
} catch (e) {
setPickerError(e.message ?? String(e));
} finally {
setPickerLoading(false);
}
};
const pickGuild = async (g2) => {
let tok;
try {
tok = requireToken();
} catch (e) {
progress("\u9700\u8981 Token", e.message);
return;
}
setPickerGuildName(g2.name);
setPickerLevel("channels");
setPickerError("");
setPickerLoading(true);
try {
const channels = await fetchChannels(tok, g2.id);
const withServerWide = g2.id === "@me" ? channels : [{ id: "", name: "\u2500\u2500 \u5168\u670D\u626B\u63CF\uFF08\u4E0D\u9650\u9891\u9053\uFF09\u2500\u2500", type: -1 }, ...channels];
setPickerChannels(withServerWide);
} catch (e) {
setPickerError(e.message ?? String(e));
} finally {
setPickerLoading(false);
}
};
const pickChannel = (ch) => {
if (!ch.id) {
setServerWide(true);
setChannelId("");
} else {
setServerWide(false);
setChannelId(ch.id);
}
setPickerOpen(false);
progress("\u5DF2\u9009\u62E9", `${pickerGuildName} \u2192 ${ch.name || "\u5168\u670D"}`);
};
const syncNow = () => {
const now = /* @__PURE__ */ new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
setBeforeStr(now.toISOString().slice(0, 16));
};
const onPreview = async () => {
let tok;
try {
tok = requireToken();
} catch (e) {
progress("\u5931\u8D25", e.message);
return;
}
let meId;
try {
meId = await fetchUserId(tok);
} catch (e) {
progress("\u5931\u8D25", e.message);
return;
}
const opts = buildOptions();
if (opts.serverWide && (!opts.guildId || opts.guildId === "@me")) {
progress("\u5931\u8D25", "\u5168\u670D\u626B\u63CF\u9700\u8981\u586B\u5199\u670D\u52A1\u5668 ID\u3002");
return;
}
if (!opts.serverWide && !opts.channelId) {
progress("\u5931\u8D25", "\u8BF7\u586B\u5199\u9891\u9053 ID\uFF0C\u6216\u6539\u7528\u5168\u670D\u626B\u63CF\u3002");
return;
}
if (opts.after && opts.before && opts.after >= opts.before) {
progress("\u5931\u8D25", "\u8D77\u59CB\u65F6\u95F4\u5FC5\u987B\u65E9\u4E8E\u7ED3\u675F\u65F6\u95F4\u3002");
return;
}
ctrlRef.current = { stopped: false };
setMode("previewing");
setPreviewed([]);
progress("\u9884\u89C8\u4E2D", "\u6B63\u5728\u626B\u63CF\u4F60\u7684\u6D88\u606F\u2026");
try {
const found = await collect(tok, opts, meId, progress, ctrlRef.current);
setPreviewed(found);
progress(ctrlRef.current.stopped ? "\u5DF2\u505C\u6B62" : "\u9884\u89C8\u5B8C\u6210", `\u627E\u5230 ${found.length} \u6761\u4F60\u7684\u6D88\u606F\u3002`);
} catch (err) {
progress("\u5931\u8D25", err.message ?? String(err));
log18.error("preview failed", err);
} finally {
setMode("idle");
}
};
const onDelete = async () => {
if (previewed.length === 0) {
progress("\u8BF7\u5148\u9884\u89C8", "");
return;
}
if (settings4.store.confirmBeforeDelete) {
const ok = window.confirm(`\u5C06\u5220\u9664 ${previewed.length} \u6761\u6D88\u606F\uFF0C\u5220\u9664\u4E0D\u53EF\u6062\u590D\uFF0C\u786E\u8BA4\u7EE7\u7EED\uFF1F`);
if (!ok) return;
}
let tok;
try {
tok = requireToken();
} catch (e) {
progress("\u5931\u8D25", e.message);
return;
}
const opts = buildOptions();
ctrlRef.current = { stopped: false };
setMode("deleting");
progress("\u5220\u9664\u4E2D", `0 / ${previewed.length}`);
try {
const result = await remove(tok, previewed, opts, progress, ctrlRef.current);
progress(
ctrlRef.current.stopped ? "\u5DF2\u505C\u6B62" : "\u5B8C\u6210",
`\u5DF2\u5220\u9664 ${result.deleted} \u6761${result.skipped ? `\uFF0C\u8DF3\u8FC7 ${result.skipped} \u6761` : ""}\u3002`
);
setPreviewed([]);
} catch (err) {
progress("\u5931\u8D25", err.message ?? String(err));
log18.error("delete failed", err);
} finally {
setMode("idle");
}
};
const onStop = () => {
ctrlRef.current.stopped = true;
progress("\u505C\u6B62\u4E2D", "\u7B49\u5F85\u5F53\u524D\u8BF7\u6C42\u7ED3\u675F\u2026");
};
const onCount = async () => {
let tok;
try {
tok = requireToken();
} catch (e) {
progress("\u5931\u8D25", e.message);
return;
}
let meId;
try {
meId = await fetchUserId(tok);
} catch (e) {
progress("\u5931\u8D25", e.message);
return;
}
const target = { guildId: guildId.trim(), channelId: serverWide ? "" : channelId.trim(), serverWide };
setStatCount(null);
progress("\u7EDF\u8BA1\u4E2D", "\u8C03\u7528\u641C\u7D22\u63A5\u53E3\u2026");
try {
const result = await count(tok, target, meId);
if (result.indexing) {
progress("\u5EFA\u7ACB\u7D22\u5F15\u4E2D", "Discord \u6B63\u5728\u5EFA\u7ACB\u7D22\u5F15\uFF0C\u7A0D\u540E\u518D\u8BD5\u3002");
return;
}
setStatCount(result.total);
progress("\u7EDF\u8BA1\u5B8C\u6210", `\u5171 ${result.total} \u6761\u53D1\u8A00\u3002`);
} catch (err) {
progress("\u5931\u8D25", err.message ?? String(err));
}
};
if (pickerOpen) {
return /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__picker-head" }, pickerLevel === "channels" && /* @__PURE__ */ React.createElement(Button, { size: "sm", variant: "plain", onClick: () => setPickerLevel("guilds") }, "\u2190 \u8FD4\u56DE"), /* @__PURE__ */ React.createElement("span", { className: "hc-cleaner__picker-title" }, pickerLevel === "guilds" ? "\u9009\u62E9\u670D\u52A1\u5668" : pickerGuildName), /* @__PURE__ */ React.createElement(Button, { size: "sm", variant: "plain", onClick: () => setPickerOpen(false) }, "\u2715")), /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__picker-list" }, pickerLoading ? /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__picker-empty" }, "\u6B63\u5728\u52A0\u8F7D\u2026") : pickerError ? /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__picker-empty hc-cleaner__picker-empty--error" }, "\u52A0\u8F7D\u5931\u8D25\uFF1A", pickerError) : pickerLevel === "guilds" ? pickerGuilds.map((g2) => /* @__PURE__ */ React.createElement(
"div",
{
key: g2.id,
className: "hc-cleaner__picker-item",
onClick: () => pickGuild(g2),
role: "button",
tabIndex: 0,
onKeyDown: (e) => {
if (e.key === "Enter") pickGuild(g2);
}
},
/* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__picker-icon" }, g2.icon ? /* @__PURE__ */ React.createElement("img", { src: `https://cdn.discordapp.com/icons/${g2.id}/${g2.icon}.png?size=64`, alt: "" }) : g2.name.charAt(0)),
/* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__picker-name" }, g2.name)
)) : pickerChannels.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__picker-empty" }, "\u6B64\u670D\u52A1\u5668\u6682\u65E0\u9891\u9053\uFF0C\u53EF\u624B\u52A8\u586B\u5199\u9891\u9053 ID\u3002") : pickerChannels.map((ch) => /* @__PURE__ */ React.createElement(
"div",
{
key: ch.id || "server-wide",
className: "hc-cleaner__picker-item",
onClick: () => pickChannel(ch),
role: "button",
tabIndex: 0,
onKeyDown: (e) => {
if (e.key === "Enter") pickChannel(ch);
}
},
/* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__picker-icon" }, ch.id ? "#" : "\u{1F310}"),
/* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__picker-name" }, ch.name)
))));
}
return /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner" }, /* @__PURE__ */ React.createElement("div", { className: "hc-inline-note hc-inline-note--danger" }, /* @__PURE__ */ React.createElement(WarningIcon, { size: 18 }), /* @__PURE__ */ React.createElement("span", null, "\u5220\u9664\u4E0D\u53EF\u6062\u590D\uFF0C\u4E14\u53EA\u4F1A\u5220\u9664", /* @__PURE__ */ React.createElement("strong", null, "\u4F60\u81EA\u5DF1"), "\u53D1\u9001\u7684\u6D88\u606F\u3002\u8BF7\u52A1\u5FC5\u5148\u9884\u89C8\u786E\u8BA4\u3002")), /* @__PURE__ */ React.createElement(Section, { title: "Token" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell--row" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, "Discord Token"), /* @__PURE__ */ React.createElement("div", { className: "hc-cell__desc" }, "\u4EE3\u8868\u4F60\u7684\u8D26\u53F7\u6743\u9650\uFF0C\u4E0D\u8981\u6CC4\u9732\u7ED9\u4EFB\u4F55\u4EBA\u3002")), /* @__PURE__ */ React.createElement(Button, { size: "sm", variant: "secondary", icon: /* @__PURE__ */ React.createElement(RefreshIcon, { size: 16 }), onClick: onAutoToken }, "\u81EA\u52A8")), /* @__PURE__ */ React.createElement("div", { className: "hc-cell__control" }, /* @__PURE__ */ React.createElement(TextInput, { value: token, onChange: setToken, placeholder: "\u81EA\u52A8\u586B\u5165\u6216\u624B\u52A8\u7C98\u8D34", type: "password" })))), /* @__PURE__ */ React.createElement(Section, { title: "\u8303\u56F4" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell hc-cell--row" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, "\u5168\u670D\u626B\u63CF"), /* @__PURE__ */ React.createElement("div", { className: "hc-cell__desc" }, "\u5FFD\u7565\u9891\u9053\uFF0C\u626B\u63CF\u6574\u4E2A\u670D\u52A1\u5668\uFF08\u8D70\u641C\u7D22\u63A5\u53E3\uFF0C\u8F83\u6162\uFF09\u3002")), /* @__PURE__ */ React.createElement(Toggle, { checked: serverWide, onChange: setServerWide, "aria-label": "\u5168\u670D\u626B\u63CF" })), /* @__PURE__ */ React.createElement("div", { className: "hc-cell" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell--row" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, "\u670D\u52A1\u5668 ID"))), /* @__PURE__ */ React.createElement("div", { className: "hc-cell__control" }, /* @__PURE__ */ React.createElement(TextInput, { value: guildId, onChange: setGuildId, placeholder: "\u670D\u52A1\u5668 ID" }))), !serverWide && /* @__PURE__ */ React.createElement("div", { className: "hc-cell" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell--row" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, "\u9891\u9053 ID"))), /* @__PURE__ */ React.createElement("div", { className: "hc-cell__control" }, /* @__PURE__ */ React.createElement(TextInput, { value: channelId, onChange: setChannelId, placeholder: "\u9891\u9053 ID" }))), /* @__PURE__ */ React.createElement("div", { className: "hc-cell hc-cell--row", style: { gap: "var(--hc-space-2)" } }, /* @__PURE__ */ React.createElement(Button, { size: "sm", variant: "secondary", icon: /* @__PURE__ */ React.createElement(ServerIcon, { size: 16 }), onClick: openPicker, disabled: running }, "\u5217\u8868"), /* @__PURE__ */ React.createElement(Button, { size: "sm", variant: "secondary", icon: /* @__PURE__ */ React.createElement(ListIcon, { size: 16 }), onClick: useCurrent, disabled: running }, "\u5F53\u524D"))), /* @__PURE__ */ React.createElement(Section, { title: "\u65F6\u95F4\u8303\u56F4", note: "\u53EF\u9009\u3002\u7559\u7A7A\u8868\u793A\u4E0D\u9650\u5236\u8BE5\u65B9\u5411\u3002" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell--row" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, "\u8D77\u59CB\u65F6\u95F4"))), /* @__PURE__ */ React.createElement("div", { className: "hc-cell__control" }, /* @__PURE__ */ React.createElement("input", { className: "hc-input", type: "datetime-local", value: afterStr, onChange: (e) => setAfterStr(e.currentTarget.value) }))), /* @__PURE__ */ React.createElement("div", { className: "hc-cell" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell--row" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, "\u7ED3\u675F\u65F6\u95F4")), /* @__PURE__ */ React.createElement(Button, { size: "sm", variant: "plain", onClick: syncNow }, "\u540C\u6B65\u6700\u65B0")), /* @__PURE__ */ React.createElement("div", { className: "hc-cell__control" }, /* @__PURE__ */ React.createElement("input", { className: "hc-input", type: "datetime-local", value: beforeStr, onChange: (e) => setBeforeStr(e.currentTarget.value) })))), /* @__PURE__ */ React.createElement(Section, { title: "\u65B9\u5411" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell hc-cell--row" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, "\u6E05\u7406\u65B9\u5411")), /* @__PURE__ */ React.createElement(
Select,
{
value: order,
onChange: setOrder,
options: [
{ value: "desc", label: "\u4ECE\u65B0\u5230\u8001" },
{ value: "asc", label: "\u4ECE\u8001\u5230\u65B0" }
]
}
))), /* @__PURE__ */ React.createElement(Section, { title: "\u786E\u8BA4", note: "\u5220\u9664\u662F\u4E0D\u53EF\u9006\u64CD\u4F5C\uFF0C\u8BF7\u5148\u9884\u89C8\u518D\u5220\u9664\u3002" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell hc-cell--row" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__main" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell__label" }, "\u6211\u786E\u8BA4\u53EA\u5220\u9664\u81EA\u5DF1\u7684\u6D88\u606F\uFF0C\u4E14\u660E\u767D\u4E0D\u53EF\u6062\u590D")), /* @__PURE__ */ React.createElement(Toggle, { checked: disclaimer, onChange: setDisclaimer, "aria-label": "\u786E\u8BA4" }))), /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__actions" }, mode === "previewing" ? /* @__PURE__ */ React.createElement(Button, { variant: "destructive", onClick: onStop }, "\u505C\u6B62\u9884\u89C8") : /* @__PURE__ */ React.createElement(Button, { variant: "primary", icon: /* @__PURE__ */ React.createElement(SearchIcon, { size: 16 }), disabled: running, onClick: onPreview }, "\u9884\u89C8"), mode === "deleting" ? /* @__PURE__ */ React.createElement(Button, { variant: "destructive", onClick: onStop }, "\u505C\u6B62\u5220\u9664") : /* @__PURE__ */ React.createElement(
Button,
{
variant: "destructive",
icon: /* @__PURE__ */ React.createElement(TrashIcon, { size: 16 }),
disabled: running || !disclaimer || previewed.length === 0,
onClick: onDelete
},
"\u5220\u9664\u9884\u89C8\uFF08",
previewed.length,
"\uFF09"
)), /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__status" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__status-state" }, state), detail && /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__status-detail" }, detail)), previewed.length > 0 && /* @__PURE__ */ React.createElement(Section, { title: `\u9884\u89C8\u7ED3\u679C\uFF08${previewed.length}\uFF09` }, /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__list" }, previewed.slice(0, 50).map((m) => /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__item", key: m.id }, /* @__PURE__ */ React.createElement("span", { className: "hc-cleaner__item-time" }, formatTs(m.timestamp)), /* @__PURE__ */ React.createElement("span", { className: "hc-cleaner__item-text" }, m.content.trim() || "\uFF08\u65E0\u6587\u672C\u5185\u5BB9\uFF09"))), previewed.length > 50 && /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__more" }, "\u2026\u8FD8\u6709 ", previewed.length - 50, " \u6761\u672A\u5C55\u793A"))), /* @__PURE__ */ React.createElement(Section, { title: "\u7EDF\u8BA1", note: "\u7EDF\u8BA1\u4F60\u5728\u6240\u9009\u8303\u56F4\u5185\u7684\u5386\u53F2\u53D1\u8A00\u603B\u6570\uFF08\u8C03\u7528\u641C\u7D22\u63A5\u53E3\uFF09\u3002" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell" }, /* @__PURE__ */ React.createElement(Button, { size: "sm", variant: "secondary", icon: /* @__PURE__ */ React.createElement(SearchIcon, { size: 16 }), disabled: running, onClick: onCount }, "\u7EDF\u8BA1\u6211\u7684\u53D1\u8A00\u6570")), statCount != null && /* @__PURE__ */ React.createElement("div", { className: "hc-cell hc-cleaner__stat" }, /* @__PURE__ */ React.createElement("span", { className: "hc-cleaner__stat-num" }, statCount), /* @__PURE__ */ React.createElement("span", { className: "hc-cleaner__stat-unit" }, "\u6761"))));
}
// src/plugins/message-cleaner/index.tsx
var log19 = logger("message-cleaner");
var message_cleaner_default = definePlugin({
id: "message-cleaner",
name: "\u6D88\u606F\u6E05\u7406",
description: "\u6279\u91CF\u5220\u9664\u4F60\u81EA\u5DF1\u5728\u67D0\u4E2A\u9891\u9053\u6216\u6574\u4E2A\u670D\u52A1\u5668\u7684\u5386\u53F2\u6D88\u606F\uFF08\u81EA\u52A9\u51B2\u6C34\u673A\uFF09\u3002\u5148\u9884\u89C8\u518D\u5220\u9664\uFF0C\u4EC5\u9650\u672C\u4EBA\u6D88\u606F\uFF0C\u5220\u9664\u4E0D\u53EF\u6062\u590D\u3002",
authors: [{ name: "caitemm" }, { name: "catie" }],
category: "privacy",
settings: settings4,
page: {
title: "\u6E05\u7406",
icon: TrashIcon,
component: CleanerPage
},
start() {
log19.info("message-cleaner ready");
},
stop() {
}
});
// src/plugins/fake-nitro/index.ts
var log20 = logger("fake-nitro");
var settings5 = defineSettings({
enableEmojiBypass: {
group: "\u8868\u60C5",
type: "boolean",
default: true,
label: "\u7ED5\u8FC7\u8868\u60C5\u9650\u5236",
description: "\u53D1\u9001\u4F60\u6CA1\u6709 Nitro \u6743\u9650\u7684\u81EA\u5B9A\u4E49\u8868\u60C5\uFF08\u8DE8\u670D / \u52A8\u6001\u8868\u60C5\uFF09\u65F6\uFF0C\u81EA\u52A8\u6539\u4E3A\u53D1\u9001\u8BE5\u8868\u60C5\u7684\u56FE\u7247\u94FE\u63A5\u3002"
},
emojiSize: {
group: "\u8868\u60C5",
type: "select",
default: "48",
label: "\u8868\u60C5\u56FE\u7247\u5C3A\u5BF8",
description: "\u5185\u8054\u8868\u60C5\u56FE\u7247\u7684\u8FB9\u957F\uFF08\u50CF\u7D20\uFF09\u3002\u8D8A\u5927\u8D8A\u6E05\u6670\u3001\u5360\u7528\u8D8A\u5927\u3002",
options: [
{ value: "32", label: "32" },
{ value: "48", label: "48\uFF08\u9ED8\u8BA4\uFF09" },
{ value: "64", label: "64" },
{ value: "128", label: "128" },
{ value: "256", label: "256" },
{ value: "512", label: "512" }
]
},
enableStickerBypass: {
group: "\u8D34\u7EB8",
type: "boolean",
default: true,
label: "\u7ED5\u8FC7\u8D34\u7EB8\u9650\u5236",
description: "\u53D1\u9001\u9501\u5B9A\u7684\u8D34\u7EB8\u65F6\u6539\u4E3A\u53D1\u9001\u8D34\u7EB8\u56FE\u7247\u94FE\u63A5\u3002Lottie\uFF08\u77E2\u91CF\uFF09\u8D34\u7EB8\u65E0\u6CD5\u5185\u8054\uFF0C\u4F1A\u8DF3\u8FC7\u3002"
},
stickerSize: {
group: "\u8D34\u7EB8",
type: "select",
default: "160",
label: "\u8D34\u7EB8\u56FE\u7247\u5C3A\u5BF8",
description: "\u5185\u8054\u8D34\u7EB8\u56FE\u7247\u7684\u8FB9\u957F\uFF08\u50CF\u7D20\uFF09\u3002",
options: [
{ value: "32", label: "32" },
{ value: "64", label: "64" },
{ value: "128", label: "128" },
{ value: "160", label: "160\uFF08\u9ED8\u8BA4\uFF09" },
{ value: "256", label: "256" },
{ value: "512", label: "512" }
]
},
enableStreamQualityBypass: {
group: "\u76F4\u64AD",
type: "boolean",
default: true,
label: "\u89E3\u9501\u76F4\u64AD\u753B\u8D28",
description: "\u5141\u8BB8\u4EE5 Nitro \u753B\u8D28\u8FDB\u884C\u5C4F\u5E55\u5171\u4EAB\u76F4\u64AD\uFF08\u9700\u91CD\u542F\u5BA2\u6237\u7AEF\u751F\u6548\uFF0C\u56E0\u4E3A\u8FD9\u662F\u6E90\u7801\u7EA7 patch\uFF09\u3002"
}
});
var EmojiStore2 = lazy((m) => m?.getName?.() === "EmojiStore");
var StickersStore2 = lazy((m) => m?.getName?.() === "StickersStore");
var GuildMemberStore = lazy((m) => m?.getName?.() === "GuildMemberStore");
var PermissionStore2 = lazy((m) => m?.getName?.() === "PermissionStore" && typeof m?.can === "function");
var PERM = {
USE_EXTERNAL_EMOJIS: 1n << 18n,
USE_EXTERNAL_STICKERS: 1n << 37n,
EMBED_LINKS: 1n << 14n
};
var STICKER_LOTTIE = 3;
var STICKER_GIF = 4;
var INTENT_CHAT = 3;
var INTENT_STICKER_EMOJI = 4;
function currentPremiumType() {
try {
return UserStore.getCurrentUser?.()?.premiumType ?? 0;
} catch {
return 0;
}
}
var canUseEmotesNatively = () => currentPremiumType() > 0;
var canUseStickersNatively = () => currentPremiumType() > 1;
function hasPermission(channelId, bit) {
try {
const channel = ChannelStore.getChannel?.(channelId);
if (!channel || channel.isPrivate?.()) return true;
return PermissionStore2.can?.(bit, channel) ?? true;
} catch {
return true;
}
}
function guildIdOfChannel(channelId) {
try {
const channel = ChannelStore.getChannel?.(channelId);
return channel?.guild_id ?? channel?.getGuildId?.() ?? void 0;
} catch {
return void 0;
}
}
function canUseEmote(emoji, channelId, guildId) {
if (emoji?.type === 0) return true;
if (emoji?.available === false) return false;
let usableManaged = false;
if (emoji?.managed && emoji?.guildId) {
const myRoles = GuildMemberStore.getSelfMember?.(emoji.guildId)?.roles ?? [];
usableManaged = Array.isArray(emoji?.roles) && emoji.roles.some((r) => myRoles.includes(r));
}
if (canUseEmotesNatively() || usableManaged) {
return emoji.guildId === guildId || hasPermission(channelId, PERM.USE_EXTERNAL_EMOJIS);
}
return !emoji?.animated && emoji?.guildId === guildId;
}
function emojiUrl(emoji) {
const size = Number(settings5.store.emojiSize) || 48;
const ext = emoji?.animated ? "gif" : "webp";
const url = new URL(`https://cdn.discordapp.com/emojis/${emoji.id}.${ext}`);
url.searchParams.set("size", String(size));
return url.toString();
}
function stickerUrl(sticker) {
const size = Number(settings5.store.stickerSize) || 160;
const ext = sticker?.format_type === STICKER_GIF ? "gif" : "png";
const url = new URL(`https://media.discordapp.net/stickers/${sticker.id}.${ext}`);
url.searchParams.set("size", String(size));
if (sticker?.name) url.searchParams.set("name", String(sticker.name));
return url.toString();
}
function wordBoundary(str, offset) {
return !str[offset] || /\s/.test(str[offset]) ? "" : " ";
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function findMessageArg(args) {
const m = args[1];
if (m && typeof m === "object" && typeof m.content === "string") return m;
return args.find((a) => a && typeof a === "object" && typeof a.content === "string");
}
function findOptionsArg(args) {
for (let i = 2; i < args.length; i++) {
const a = args[i];
if (a && typeof a === "object" && "stickerIds" in a) return a;
}
return args[3] && typeof args[3] === "object" ? args[3] : void 0;
}
function rewriteStickers(channelId, message, options, guildId) {
if (!settings5.store.enableStickerBypass) return false;
const ids = options?.stickerIds;
if (!Array.isArray(ids) || ids.length === 0) return false;
const sticker = StickersStore2.getStickerById?.(ids[0]);
if (!sticker) return false;
if ("pack_id" in sticker) return false;
const canUse = canUseStickersNatively() && hasPermission(channelId, PERM.USE_EXTERNAL_STICKERS);
if (sticker.available !== false && (canUse || sticker.guild_id === guildId)) return false;
if (sticker.format_type === STICKER_LOTTIE) {
log20.warn("Lottie \u8D34\u7EB8\u65E0\u6CD5\u4F5C\u4E3A\u56FE\u7247\u5185\u8054\uFF0C\u5DF2\u8DF3\u8FC7\uFF1A", sticker.name);
return false;
}
const url = stickerUrl(sticker);
message.content = `${message.content ?? ""}${wordBoundary(message.content ?? "", (message.content ?? "").length - 1)}${url}`;
ids.length = 0;
return true;
}
var EMOJI_TOKEN_RE = /(?<!\\)<(a)?:(\w+):(\d+)>/gi;
function rewriteEmojis(channelId, message, guildId) {
if (!settings5.store.enableEmojiBypass) return false;
let changed = false;
const emojis = message?.validNonShortcutEmojis;
if (Array.isArray(emojis) && emojis.length > 0) {
for (const emoji of emojis) {
if (canUseEmote(emoji, channelId, guildId)) continue;
const token = `<${emoji.animated ? "a" : ""}:${emoji.originalName || emoji.name}:${emoji.id}>`;
const url = emojiUrl(emoji);
const re = new RegExp(escapeRegExp(token), "g");
message.content = String(message.content ?? "").replace(
re,
(match, offset, str) => {
changed = true;
return `${wordBoundary(str, offset - 1)}${url}${wordBoundary(str, offset + match.length)}`;
}
);
}
}
const before = String(message.content ?? "");
EMOJI_TOKEN_RE.lastIndex = 0;
if (before.length > 0 && EMOJI_TOKEN_RE.test(before)) {
EMOJI_TOKEN_RE.lastIndex = 0;
const rewritten = before.replace(
EMOJI_TOKEN_RE,
(tokenStr, animatedFlag, _name, emojiId, offset, str) => {
const cached2 = EmojiStore2.getCustomEmojiById?.(emojiId);
if (cached2 && canUseEmote(cached2, channelId, guildId)) return tokenStr;
changed = true;
const url = emojiUrlFromParts(emojiId, Boolean(animatedFlag));
return `${wordBoundary(str, offset - 1)}${url}${wordBoundary(str, offset + tokenStr.length)}`;
}
);
if (rewritten !== before) message.content = rewritten;
}
return changed;
}
function emojiUrlFromParts(id, animated) {
const size = Number(settings5.store.emojiSize) || 48;
const ext = animated ? "gif" : "webp";
const url = new URL(`https://cdn.discordapp.com/emojis/${id}.${ext}`);
url.searchParams.set("size", String(size));
return url.toString();
}
var unpatchSend;
var unpatchEdit;
function onSendMessage(ctx) {
try {
const args = ctx.args;
const channelId = args[0];
const message = findMessageArg(args);
if (!message) return;
if (message.__fakeNitroRewritten) return;
if (typeof message.content !== "string") message.content = String(message.content ?? "");
const options = findOptionsArg(args);
const guildId = guildIdOfChannel(channelId);
if (options) rewriteStickers(channelId, message, options, guildId);
rewriteEmojis(channelId, message, guildId);
} catch (err) {
log20.error("send \u6539\u5199\u5931\u8D25\uFF0C\u6D88\u606F\u6309\u539F\u6837\u53D1\u9001", err);
}
}
function onEditMessage(ctx) {
try {
if (!settings5.store.enableEmojiBypass) return;
const args = ctx.args;
const channelId = args[0];
const message = findMessageArg(args);
if (!message || typeof message.content !== "string") return;
const guildId = guildIdOfChannel(channelId);
message.content = message.content.replace(
EMOJI_TOKEN_RE,
(tokenStr, animatedFlag, _name, emojiId, offset, str) => {
const cached2 = EmojiStore2.getCustomEmojiById?.(emojiId);
if (cached2 && canUseEmote(cached2, channelId, guildId)) return tokenStr;
const url = emojiUrlFromParts(emojiId, Boolean(animatedFlag));
return `${wordBoundary(str, offset - 1)}${url}${wordBoundary(str, offset + tokenStr.length)}`;
}
);
} catch (err) {
log20.error("edit \u6539\u5199\u5931\u8D25\uFF0C\u6D88\u606F\u6309\u539F\u6837\u4FDD\u5B58", err);
}
}
function reportPatches2() {
const mine = getSourcePatchReport().filter((p) => p.pluginId === "fake-nitro");
if (!mine.length) return;
const missed = mine.filter((p) => !p.applied);
if (missed.length === 0) {
log20.info("\u6240\u6709\u6E90\u7801 patch \u5747\u5DF2\u5728\u5F53\u524D Discord \u7248\u672C\u751F\u6548");
} else {
log20.warn(
"\u90E8\u5206\u6E90\u7801 patch \u672A\u5339\u914D\u5F53\u524D Discord \u7248\u672C\uFF1B\u9009\u62E9\u5668\u89E3\u9501\u6216\u53D1\u9001\u6539\u5199\u53EF\u80FD\u4E0D\u5B8C\u6574\u3002\u672A\u5339\u914D\uFF1A" + missed.map((p) => `\u201C${p.label}\u201D`).join("\u3001")
);
}
}
var IS_BYPASSEABLE_INTENTION = `[${INTENT_CHAT},${INTENT_STICKER_EMOJI}].includes(fakeNitroIntention)`;
var fake_nitro_default = definePlugin({
id: "fake-nitro",
name: "\u5047 Nitro",
description: "\u65E0\u9700 Nitro \u4E5F\u80FD\u4F7F\u7528\u9700\u8981 Nitro \u7684\u81EA\u5B9A\u4E49\u8868\u60C5\u4E0E\u8D34\u7EB8\uFF1A\u89E3\u9501\u9009\u62E9\u5668\uFF0C\u5E76\u5728\u53D1\u9001\u65F6\u628A\u9501\u5B9A\u7684\u8868\u60C5 / \u8D34\u7EB8\u81EA\u52A8\u6539\u5199\u4E3A\u56FE\u7247\u94FE\u63A5\uFF0C\u5BF9\u65B9\u770B\u5230\u7684\u5C31\u662F\u5185\u8054\u56FE\u7247\u3002\u4FEE\u6539\u9700\u91CD\u542F\u5BA2\u6237\u7AEF\u624D\u80FD\u5B8C\u5168\u751F\u6548\u3002",
authors: [{ name: "Vencord" }, { name: "caitemm" }],
category: "chat",
settings: settings5,
patches: [
// 0. THE send-time fix — pre-send rewrite, same seam as Vencord's
// MessageEventsAPI (find: ".handleSendMessage,onResize:"), but the match
// is re-anchored to THIS build's `handleSendMessage`, verified against
// the dumped module source. Vencord's upstream regex expects
// `parse(channel,...).getSendMessageOptions({...});` back-to-back; this
// build instead reads:
//
// let w=X.Y.parse(h,t); // w = parsed draft (validNonShortcutEmojis)
// w.tts=...; ...
// let I={...Z.getSendMessageOptions({content:t,channelId:h.id,...}),
// location:...}; // I = send options (stickerIds)
// if(...) <send>
//
// So we capture w (message), h (channel), I (options) and splice our
// rewrite in right after `let I={...};` — before the send, and before
// the client-side "该表情符号为动态表情" check that otherwise aborts an
// animated / cross-server emoji locally. By then the `<a:name:id>` token
// is already a CDN URL, so the check sees a plain link and lets it through.
//
// Hooking MessageActions.sendMessage (the old approach) fired AFTER that
// block already killed the send — which is why the emoji came back
// "无法使用" no matter what.
{
label: "message pre-send rewrite",
find: /handleSendMessage[\s\S]{0,200}onResize|getSendMessageOptions[\s\S]{0,500}handleSendMessage/,
replacement: {
match: /let ([\w$]+)=[\w$]+\.[\w$]+\.parse\(([\w$]+),[\w$]+\);.+?let ([\w$]+)=\{\.\.\.[\w$]+\.[\w$]+\.getSendMessageOptions\(\{.+?\}\),location:[^}]*\};/,
replace: (m, msg, channel, options) => `${m}if($self.handlePreSend(${channel}.id,${msg},${options}))return{shouldClear:false,shouldRefocus:true};`
}
},
// 1. Premium predicate bypass. The `canUse…` helpers under PremiumUtils
// gate every "is this a premium feature?" check in the client. Rewrite
// each function body to just `return true;` so the picker, sticker
// dropdowns, and stream-quality options all show as usable.
{
label: "premium predicates return true",
find: "canUseCustomStickersEverywhere:",
replacement: [
{
match: /(?<=canUseCustomStickersEverywhere:function\([\w$]+\)\{)/,
replace: "return true;"
},
{
match: /(?<=canUseHighVideoUploadQuality:function\([\w$]+\)\{)/,
replace: "return true;"
},
{
match: /(?<=canStreamQuality:function\([\w$]+,[\w$]+\)\{)/,
replace: "return true;"
},
{
match: /(?<=canUseClientThemes:function\([\w$]+\)\{)/,
replace: "return true;"
},
{
match: /(?<=canUsePremiumAppIcons:function\([\w$]+\)\{)/,
replace: "return true;"
}
]
},
// 2. Voice-call emoji picker keeps its native (server-side) restriction:
// fake-nitro shouldn't try to bypass emoji use in voice channels where
// the intention isn't CHAT. Vencord swaps the CHAT constant for STATUS
// at this exact call site so our picker patches leave voice alone.
{
label: "voice call emoji stays native",
find: '.getByName("fork_and_knife")',
replacement: {
match: /\.CHAT/,
replace: ".STATUS"
}
},
// 3. Emoji picker unlock. The module that produces the "why is this emoji
// disabled?" enum has multiple gates: DISALLOW_EXTERNAL,
// GUILD_SUBSCRIPTION_UNAVAILABLE, premium-locked, animated-locked.
// Widen each so intentions CHAT (3) and GUILD_STICKER_RELATED_EMOJI (4)
// pass, giving the picker "usable" for anything we're about to rewrite
// into a URL anyway.
{
label: "emoji picker unlock",
find: ".GUILD_SUBSCRIPTION_UNAVAILABLE;",
replacement: [
// Introduce `fakeNitroIntention`, bound to Discord's own intention
// variable, so the widenings below can key off it.
//
// Vencord anchors this on the literal `intention:` token — but that
// token is ABSENT in the current build (verified against the dumped
// module source): the intention is a bare minified variable, e.g.
// `d`, compared as `d===X.EmojiIntention.STATUS`. Anchoring on the
// (present) `.USE_EXTERNAL_EMOJIS,x);` statement boundary and reading
// the intention variable out of the nearby
// `isExternalEmojiAllowedForIntention(d)` call is what actually lands.
//
// This one MUST match: patches #2–#5 below all reference
// `fakeNitroIntention`, so if this fails while they apply, the emoji
// function throws ReferenceError on every call and the picker greys
// out everything — which is exactly the "全是锁" symptom this fixes.
{
match: /(?<=\.USE_EXTERNAL_EMOJIS,[\w$]+\);)(?=.{0,300}?isExternalEmojiAllowedForIntention\)\(([\w$]+)\))/,
replace: "const fakeNitroIntention=$1;"
},
// DISALLOW_EXTERNAL: bypass for our intentions.
{
match: /&&![\w$]+&&![\w$]+(?=\)return [\w$]+\.[\w$]+\.DISALLOW_EXTERNAL;)/,
replace: `$&&&!${IS_BYPASSEABLE_INTENTION}`
},
// GUILD_SUBSCRIPTION_UNAVAILABLE: bypass for our intentions.
{
match: /![\w$]+\.available(?=\)return [\w$]+\.[\w$]+\.GUILD_SUBSCRIPTION_UNAVAILABLE;)/,
replace: `$&&&!${IS_BYPASSEABLE_INTENTION}`
},
// "You need premium for cross-server emoji": bypass for our intentions.
{
match: /!([\w$]+\.[\w$]+\.canUseEmojisEverywhere\([\w$]+\))/,
replace: `(!$1&&!${IS_BYPASSEABLE_INTENTION})`
},
// "You need premium for animated emoji": pretend we can, for our intentions.
{
match: /(?<=\|\|)[\w$]+\.[\w$]+\.canUseAnimatedEmojis\([\w$]+\)/,
replace: `($&||${IS_BYPASSEABLE_INTENTION})`
}
]
},
// 4. Subscription-locked (role-benefit) emoji unlock. A guarded predicate
// returns false when the current user lacks admin on the role-benefit
// guild. Route calls that WE make (with a fakeNitroOriginal=true tail
// arg) through the original, and let everyone else's calls (i.e. the
// picker's own probe) short-circuit to "usable".
{
label: "subscription emoji unlock",
find: ".getUserIsAdmin(",
replacement: {
match: /(function [\w$]+\([\w$]+,[\w$]+)\)\{(.{0,250}\.getUserIsAdmin\(.+?return!1\})/,
replace: "$1,fakeNitroOriginal){if(!fakeNitroOriginal)return false;$2"
}
},
// 5. Sticker always "SENDABLE". Same trick: rewrite the availability
// predicate at the sticker send-affordance site so the picker doesn't
// grey out locked stickers before our runtime send hook can rewrite
// them into image links.
{
label: "stickers always sendable",
find: '"SENDABLE"',
replacement: {
match: /[\w$]+\.available\?/,
replace: "true?"
}
},
// 6. Stream quality: drop the `guildPremiumTier: TIER_x,` requirements
// from the stream FPS / resolution options so all quality tiers are
// picker-visible regardless of the server's boost level.
//
// NOTE: Vencord's find is the intl macro `#{intl::STREAM_FPS_OPTION}`,
// which its build step rewrites into the real (hashed) runtime lookup.
// This runtime has no such transform, so that literal never appears in
// Discord's code. We fall back to the bare `STREAM_FPS_OPTION` token as
// a best effort; if the build hashes it away this patch simply no-ops
// (stream quality is a minor extra — it never blocks emoji/sticker use).
{
label: "stream quality tiers removed",
find: "STREAM_FPS_OPTION",
all: true,
replacement: {
match: /guildPremiumTier:[\w$]+\.[\w$]+\.TIER_\d,?/,
replace: ""
}
},
// 7. Custom desktop app icons — the picker checks `isPremium(currentUser)`.
// Force true. Small quality-of-life patch that comes free with the
// premium-bypass mood.
{
label: "custom app icons",
find: "getCurrentDesktopIcon(),",
replacement: {
match: /[\w$]+\.[\w$]+\.isPremium\([\w$]+\.[\w$]+\.getCurrentUser\(\)\)/,
replace: "true"
}
},
// 8. Custom client themes — a `isTier2Above` gate on the custom-theme
// editor. Force true so the editor unlocks for the user.
{
label: "custom client themes",
find: '("custom_themes_editor_footer")',
all: true,
replacement: {
match: /\(0,[\w$]+\.[\w$]+\)\([\w$]+\.[\w$]+\.TIER_2\)(?=,|;)/,
replace: "true"
}
},
// 9. Soundboard sounds — `available` fields arrive as false for locked
// sounds on non-Nitro accounts. Force them to true on the ingest
// reducers so the picker treats them as usable.
{
label: "soundboard sounds available",
find: 'type:"GUILD_SOUNDBOARD_SOUND_CREATE"',
all: true,
replacement: {
match: /(?<=type:"(?:SOUNDBOARD_SOUNDS_RECEIVED|GUILD_SOUNDBOARD_SOUND_CREATE|GUILD_SOUNDBOARD_SOUND_UPDATE|GUILD_SOUNDBOARD_SOUNDS_UPDATE)".+?available:)[\w$]+\.available/,
replace: "true"
}
}
],
start() {
const messageActions = findByProps("sendMessage", "editMessage", "deleteMessage");
if (messageActions) {
if (typeof messageActions.sendMessage === "function") {
try {
unpatchSend = patcher.before(messageActions, "sendMessage", onSendMessage);
} catch (err) {
log20.error("\u6302\u63A5 sendMessage \u5931\u8D25", err);
}
}
if (typeof messageActions.editMessage === "function") {
try {
unpatchEdit = patcher.before(messageActions, "editMessage", onEditMessage);
} catch (err) {
log20.error("\u6302\u63A5 editMessage \u5931\u8D25", err);
}
}
log20.info("MessageActions \u5DF2\u6302\u63A5\uFF08\u53D1\u9001 / \u7F16\u8F91\u6539\u5199\u5C31\u7EEA\uFF1B\u82E5 pre-send \u8865\u4E01\u5DF2\u751F\u6548\u5219\u6B64 hook \u4EC5\u4F5C fallback\uFF09");
} else {
log20.warn(
"\u672A\u627E\u5230 MessageActions \u2014\u2014 \u9009\u62E9\u5668\u89E3\u9501\u5DF2\u901A\u8FC7\u6E90\u7801 patch \u751F\u6548\uFF0C\u4F46\u53D1\u9001\u65F6\u7684 URL \u6539\u5199\u4E0D\u53EF\u7528\u3002\u91CD\u542F\u5BA2\u6237\u7AEF\u540E\u518D\u8BD5\uFF1B\u82E5\u4ECD\u672A\u627E\u5230\uFF0C\u8BF4\u660E\u8BE5 Discord \u7248\u672C\u7684 MessageActions \u5F62\u72B6\u6709\u53D8\u3002"
);
}
setTimeout(reportPatches2, 4e3);
},
stop() {
unpatchSend?.();
unpatchEdit?.();
unpatchSend = void 0;
unpatchEdit = void 0;
},
/**
* Called from the pre-send source patch (#0) with Discord's freshly parsed
* draft — at the exact point Vencord's MessageEventsAPI fires, after parse
* and before send. Rewrites locked sticker ids and emoji tokens in
* `messageObj` IN PLACE into CDN URLs. Returns false so the send is never
* cancelled: we've already mutated the draft, there's nothing to abort.
* Guarded end-to-end — a throw here must never break the send box.
*/
handlePreSend(channelId, messageObj, options) {
try {
if (typeof messageObj?.content !== "string") {
messageObj.content = String(messageObj?.content ?? "");
}
const guildId = guildIdOfChannel(channelId);
if (options) rewriteStickers(channelId, messageObj, options, guildId);
rewriteEmojis(channelId, messageObj, guildId);
messageObj.__fakeNitroRewritten = true;
} catch (err) {
log20.error("pre-send \u6539\u5199\u5931\u8D25\uFF0C\u6D88\u606F\u6309\u539F\u6837\u53D1\u9001", err);
}
return false;
}
});
// src/plugins/console-cleaner/index.ts
var log21 = logger("console-cleaner");
var settings6 = defineSettings({
hideSelfXss: {
group: "\u5185\u7F6E\u89C4\u5219",
type: "boolean",
default: true,
label: "\u5C4F\u853D\u81EA\u6211 XSS \u8B66\u544A",
description: "Discord \u90A3\u6761\u6BCF\u79D2\u91CD\u5237\u7684\u7EA2\u8272\u201C\u7B49\u4E00\u4E0B\uFF01/ Stop!\u201D\u7C98\u8D34\u8B66\u544A\u3002"
},
hideLocaleSpam: {
group: "\u5185\u7F6E\u89C4\u5219",
type: "boolean",
default: true,
label: "\u5C4F\u853D\u672C\u5730\u5316\u7F3A\u5931\u5237\u5C4F",
description: "\u201C\u2026 does not have a value in the requested locale \u2026\u201D\uFF0C\u5BA2\u6237\u7AEF mod \u8BA2\u9605\u4E8B\u4EF6\u65F6\u4F1A\u75AF\u72C2\u5237\u3002"
},
hideRiveSpam: {
group: "\u5185\u7F6E\u89C4\u5219",
type: "boolean",
default: true,
label: "\u5C4F\u853D Rive \u52A8\u753B\u62A5\u9519",
description: "\u201CCould not find a View Model linked to Artboard \u2026\u201D\uFF0C\u9644\u5E26\u8D85\u957F wasm \u5806\u6808\u3002"
},
hidePreloadWarnings: {
group: "\u5185\u7F6E\u89C4\u5219",
type: "boolean",
default: true,
label: "\u5C4F\u853D\u8D44\u6E90\u9884\u52A0\u8F7D\u8B66\u544A",
description: "\u201Cresource was preloaded using link preload but not used \u2026\u201D\u3002\u89C1\u4E0B\u65B9\u8BF4\u660E\uFF1A\u90E8\u5206\u6B64\u7C7B\u8B66\u544A\u7531\u6D4F\u89C8\u5668\u76F4\u63A5\u4EA7\u751F\uFF0C\u65E0\u6CD5\u62E6\u622A\u3002"
},
customPatterns: {
group: "\u81EA\u5B9A\u4E49",
type: "string-list",
default: [],
label: "\u81EA\u5B9A\u4E49\u5C4F\u853D\u5173\u952E\u8BCD",
description: "\u4EFB\u4F55\u4E00\u6761 console \u6D88\u606F\u53EA\u8981\u5305\u542B\u8FD9\u91CC\u7684\u67D0\u4E2A\u5B50\u4E32\uFF0C\u5C31\u4F1A\u88AB\u4E22\u5F03\uFF08\u533A\u5206\u5927\u5C0F\u5199\uFF09\u3002",
itemPlaceholder: "\u8981\u5C4F\u853D\u7684\u6587\u5B57\u7247\u6BB5"
}
});
var SELF_XSS_NEEDLES = [
"\u7B49\u4E00\u4E0B",
"\u5728\u8FD9\u91CC\u7C98\u8D34",
"\u5982\u679C\u6709\u4EBA\u544A\u8BC9\u60A8",
"\u8BF7\u5173\u95ED\u6B64\u7A97\u53E3",
"Stop!",
"self-XSS",
"browser feature intended for developers",
"This is a browser feature",
"Nicht so schnell",
"Attends",
"Alto",
"\u3061\u3087\u3063\u3068\u5F85\u3063\u3066",
"\uC7A0\uAE50"
];
var LOCALE_NEEDLES = ["does not have a value in the requested locale"];
var RIVE_NEEDLES = [
"Could not find a View Model linked to Artboard",
"BaseGlowRemapped"
];
var PRELOAD_NEEDLES = [
"was preloaded using link preload",
"preloaded intentionally"
];
var METHODS = ["log", "info", "warn", "error", "debug"];
function textOf(args) {
let out = "";
for (const a of args) {
if (typeof a === "string") out += a + " ";
else if (typeof a === "number" || typeof a === "boolean") out += String(a) + " ";
}
return out;
}
function anyNeedle(text, needles) {
for (const n of needles) if (n && text.includes(n)) return true;
return false;
}
function shouldSuppress(args) {
if (typeof args[0] === "string" && args[0].startsWith("%cHalcyon")) return false;
const text = textOf(args);
if (text === "") return false;
const s = settings6.store;
if (s.hideSelfXss && anyNeedle(text, SELF_XSS_NEEDLES)) return true;
if (s.hideLocaleSpam && anyNeedle(text, LOCALE_NEEDLES)) return true;
if (s.hideRiveSpam && anyNeedle(text, RIVE_NEEDLES)) return true;
if (s.hidePreloadWarnings && anyNeedle(text, PRELOAD_NEEDLES)) return true;
if (s.customPatterns.length && anyNeedle(text, s.customPatterns)) return true;
return false;
}
var unpatchers = [];
var suppressedCount = 0;
function makeHook() {
return (ctx) => {
try {
if (shouldSuppress(ctx.args)) {
suppressedCount++;
return void 0;
}
} catch {
}
return ctx.callOriginal();
};
}
var console_cleaner_default = definePlugin({
id: "console-cleaner",
name: "\u63A7\u5236\u53F0\u51C0\u5316",
description: "\u5C4F\u853D Discord \u5728\u5F00\u53D1\u8005\u63A7\u5236\u53F0\u91CC\u5237\u5C4F\u7684\u65E0\u7528\u4FE1\u606F\uFF08\u81EA\u6211 XSS \u8B66\u544A\u3001Rive \u52A8\u753B\u62A5\u9519\u3001\u672C\u5730\u5316\u7F3A\u5931\u3001\u8D44\u6E90\u9884\u52A0\u8F7D\u8B66\u544A\uFF09\uFF0C\u652F\u6301\u81EA\u5B9A\u4E49\u5173\u952E\u8BCD\u3002\u5173\u95ED\u63D2\u4EF6\u5373\u6062\u590D\u539F\u59CB console\u3002",
authors: [{ name: "caitemm" }, { name: "catie" }],
category: "utility",
settings: settings6,
start() {
const con = globalThis.console;
if (!con) {
log21.warn("\u672A\u627E\u5230 console \u5BF9\u8C61\uFF0C\u63D2\u4EF6\u65E0\u4E8B\u53EF\u505A");
return;
}
suppressedCount = 0;
const hook = makeHook();
for (const method of METHODS) {
if (typeof con[method] === "function") {
try {
unpatchers.push(patcher.instead(con, method, hook));
} catch (err) {
log21.error(`\u6302\u63A5 console.${method} \u5931\u8D25`, err);
}
}
}
log21.info(
`\u5DF2\u51C0\u5316 console\uFF08\u62E6\u622A ${unpatchers.length} \u4E2A\u65B9\u6CD5\uFF09\u3002\u6CE8\u610F\uFF1A\u6D4F\u89C8\u5668\u81EA\u8EAB\u4EA7\u751F\u7684\u8B66\u544A\uFF08\u5982\u67D0\u4E9B preload \u63D0\u793A\uFF09\u65E0\u6CD5\u901A\u8FC7 JS \u62E6\u622A\u3002`
);
},
stop() {
for (const undo of unpatchers) {
try {
undo();
} catch {
}
}
unpatchers = [];
log21.info(`\u5DF2\u6062\u590D\u539F\u59CB console\uFF08\u672C\u6B21\u5171\u5C4F\u853D ${suppressedCount} \u6761\u6D88\u606F\uFF09`);
}
});
// src/plugins/emote-cloner/clone.ts
var log22 = logger("emote-cloner");
var MAX_EMOJI_SIZE_BYTES = 256 * 1024;
var MAX_STICKER_SIZE_BYTES = 512 * 1024;
var uploadEmojiAction = null;
function getUploadEmoji() {
if (uploadEmojiAction) return uploadEmojiAction;
uploadEmojiAction = findByCode(".GUILD_EMOJIS(", "EMOJI_UPLOAD_START") ?? null;
return uploadEmojiAction;
}
function sanitizeEmojiName(name) {
let n = (name || "emoji").split("~")[0].replace(/[^\w]/g, "_");
if (n.length < 2) n = `${n}_e`;
return n.slice(0, 32);
}
function stickerExt(formatType) {
if (formatType === 4) return "gif";
if (formatType === 3) return "json";
return "png";
}
function emojiUrl2(id, size) {
return `https://cdn.discordapp.com/emojis/${id}.webp?size=${size}&lossless=true&animated=true`;
}
function stickerUrl2(id, ext, size) {
return `https://media.discordapp.net/stickers/${id}.${ext}?size=${size}&lossless=true&animated=true`;
}
async function fetchBlobUnderLimit(makeUrl, maxBytes) {
for (let size = 4096; size >= 16; size /= 2) {
const url = makeUrl(size);
const res = await fetch(url);
if (!res.ok) throw new Error(`\u4E0B\u8F7D\u56FE\u7247\u5931\u8D25\uFF1AHTTP ${res.status}`);
const blob = await res.blob();
if (blob.size <= maxBytes) return blob;
}
throw new Error(`\u56FE\u7247\u8D85\u51FA\u5927\u5C0F\u9650\u5236\uFF08${Math.round(maxBytes / 1024)}KB\uFF09`);
}
function blobToDataUri(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error ?? new Error("\u8BFB\u53D6\u56FE\u7247\u5931\u8D25"));
reader.readAsDataURL(blob);
});
}
function resPayload(res) {
if (res == null) return null;
if (res.body != null && !(typeof res.body === "object" && Object.keys(res.body).length === 0)) {
return res.body;
}
if (typeof res.text === "string" && res.text) {
try {
return JSON.parse(res.text);
} catch {
}
}
return res.body ?? null;
}
function restErrorMessage(err) {
const body = err?.body ?? err?.response?.body;
if (body) {
try {
const walk = (o) => {
if (!o || typeof o !== "object") return void 0;
if (Array.isArray(o._errors) && o._errors[0]?.message) return o._errors[0].message;
for (const k of Object.keys(o)) {
const hit = walk(o[k]);
if (hit) return hit;
}
return void 0;
};
const specific = walk(body.errors);
if (specific) return specific;
} catch {
}
if (typeof body.message === "string") return body.message;
}
if (typeof err?.text === "string") {
try {
const parsed = JSON.parse(err.text);
if (parsed?.message) return parsed.message;
} catch {
}
}
return err?.message ? String(err.message) : "\u672A\u77E5\u9519\u8BEF";
}
async function cloneEmoji(guildId, emoji) {
const blob = await fetchBlobUnderLimit((size) => emojiUrl2(emoji.id, size), MAX_EMOJI_SIZE_BYTES);
const image = await blobToDataUri(blob);
const name = sanitizeEmojiName(emoji.name);
const upload = getUploadEmoji();
if (typeof upload === "function") {
try {
await upload({ guildId, name, image });
return;
} catch (err) {
log22.error("emoji \u4E0A\u4F20\uFF08action\uFF09\u5931\u8D25", err);
throw new Error(restErrorMessage(err));
}
}
try {
await RestAPI.post({ url: `/guilds/${guildId}/emojis`, body: { image, name, roles: [] } });
} catch (err) {
log22.error("emoji \u4E0A\u4F20\uFF08REST\uFF09\u5931\u8D25", err);
throw new Error(restErrorMessage(err));
}
}
async function fetchStickerInfo(id) {
try {
const cached2 = StickersStore.getStickerById?.(id);
if (cached2) return cached2;
} catch {
}
try {
const res = await RestAPI.get({ url: `/stickers/${id}` });
const body = resPayload(res);
if (body) {
try {
getDispatcher()?.dispatch({ type: "STICKER_FETCH_SUCCESS", sticker: body });
} catch {
}
}
return body;
} catch (err) {
log22.warn("could not fetch sticker info; using fallbacks", err);
return null;
}
}
async function cloneSticker(guildId, sticker) {
const info = await fetchStickerInfo(sticker.id);
if (info?.format_type === 3) {
throw new Error("\u8FD9\u662F Lottie \u52A8\u6001\u8D34\u7EB8\uFF0C\u65E0\u6CD5\u590D\u5236");
}
const name = (info?.name || sticker.name || "sticker").slice(0, 30);
const tags = sticker.tags || info?.tags || "\u{1F642}";
const description = (sticker.description ?? info?.description ?? "").slice(0, 100);
const ext = stickerExt(info?.format_type);
const blob = await fetchBlobUnderLimit(
(size) => stickerUrl2(sticker.id, ext, size),
MAX_STICKER_SIZE_BYTES
);
const form = new FormData();
form.append("name", name);
form.append("tags", tags);
form.append("description", description);
form.append("file", new File([blob], `sticker.${ext}`, { type: ext === "gif" ? "image/gif" : "image/png" }));
const url = Constants?.Endpoints?.GUILD_STICKER_PACKS?.(guildId) ?? `/guilds/${guildId}/stickers`;
let created;
try {
const res = await RestAPI.post({ url, body: form });
created = resPayload(res);
if (created && !created.id && created.sticker?.id) created = created.sticker;
} catch (err) {
log22.error("sticker \u4E0A\u4F20\u5931\u8D25", err);
throw new Error(restErrorMessage(err));
}
log22.info("sticker uploaded", { id: created?.id, name: created?.name });
try {
getDispatcher()?.dispatch({
type: "GUILD_STICKERS_CREATE_SUCCESS",
guildId,
sticker: { ...created, user: UserStore.getCurrentUser?.() }
});
} catch {
}
}
// src/plugins/emote-cloner/resolve.ts
var log23 = logger("emote-cloner");
var SNOWFLAKE = /^\d{5,25}$/;
var EMOJI_NAME = /^\w{1,32}(?:~\d+)?$/;
function emojiName(raw) {
if (typeof raw !== "string") return void 0;
const n = raw.replace(/:/g, "").trim();
return EMOJI_NAME.test(n) ? n : void 0;
}
function stickerName(raw) {
if (typeof raw !== "string") return void 0;
const n = raw.trim();
return n && n.length <= 30 && !n.includes("\n") ? n : void 0;
}
function isGifUrl(url) {
if (!url) return false;
try {
const u = new URL(url, location.href);
return u.pathname.endsWith(".gif") || u.searchParams.get("animated") === "true";
} catch {
return /\.gif(\?|$)/.test(url) || url.includes("animated=true");
}
}
function parseEmojiUrl(src) {
const m = src.match(/\/emojis\/(\d+)\.(\w+)/);
if (!m) return null;
let name;
try {
const raw = new URL(src, location.href).searchParams.get("name");
name = raw ? decodeURIComponent(raw) : void 0;
} catch {
}
return { id: m[1], isAnimated: m[2] === "gif" || /animated=true/.test(src), name };
}
function parseStickerUrl(src) {
const m = src.match(/\/stickers\/(\d+)\./);
return m ? { id: m[1] } : null;
}
function isLottie(el) {
return String(el?.className ?? "").toLowerCase().includes("lottie");
}
function gatherImages(target) {
const seen = /* @__PURE__ */ new Set();
const out = [];
const add = (el) => {
if (el && el.tagName === "IMG" && !seen.has(el)) {
seen.add(el);
out.push(el);
}
};
add(target);
target.querySelectorAll?.("img").forEach(add);
let cur = target.parentElement;
for (let depth = 0; depth < 4 && cur; depth++, cur = cur.parentElement) {
add(cur);
cur.querySelectorAll?.(":scope > img").forEach(add);
}
return out;
}
function selfAndAncestors(target, depth = 5) {
const out = [];
let cur = target;
for (let i = 0; cur && i <= depth; i++, cur = cur.parentElement) out.push(cur);
return out;
}
var SCAN_MAX_DEPTH = 5;
var SCAN_MAX_NODES = 900;
function findRecordById(root, id) {
let budget = SCAN_MAX_NODES;
const seen = /* @__PURE__ */ new Set();
const walk = (value, depth) => {
if (value == null || typeof value !== "object") return null;
if (depth > SCAN_MAX_DEPTH || budget-- <= 0) return null;
if (seen.has(value)) return null;
seen.add(value);
if (Array.isArray(value)) {
for (const item of value) {
const hit = walk(item, depth + 1);
if (hit) return hit;
}
return null;
}
if (value.$$typeof != null || value.nodeType != null || value.stateNode != null) return null;
try {
if (String(value.id ?? "") === id && typeof value.name === "string") {
return { name: value.name, animated: Boolean(value.animated ?? value.isAnimated) };
}
if (typeof value.emojiName === "string" && String(value.emojiId ?? "") === id) {
return { name: value.emojiName, animated: Boolean(value.animated ?? value.isAnimated) };
}
} catch {
}
let keys;
try {
keys = Object.keys(value);
} catch {
return null;
}
for (const key of keys) {
if (key.charCodeAt(0) === 95) continue;
let child;
try {
child = value[key];
} catch {
continue;
}
if (child == null || typeof child !== "object") continue;
const hit = walk(child, depth + 1);
if (hit) return hit;
}
return null;
};
return walk(root, 0);
}
function recordFromFiber(target, id) {
for (const props of getFiberPropsChain(target)) {
const hit = findRecordById(props, id);
if (hit) return hit;
}
return null;
}
function messageFromDom(target) {
const el = target.closest?.(
"[id^='chat-messages-'],[data-list-item-id*='chat-messages']"
);
if (!el) return null;
const raw = el.id || el.dataset?.listItemId || "";
const ids = raw.match(/\d{5,25}/g);
if (!ids || ids.length === 0) return null;
const messageId = ids[ids.length - 1];
let channelId = ids.length > 1 ? ids[ids.length - 2] : void 0;
try {
channelId ??= SelectedChannelStore.getChannelId?.();
} catch {
}
if (!channelId) return null;
try {
return MessageStore.getMessage?.(channelId, messageId) ?? null;
} catch {
return null;
}
}
function messagesNear(target) {
const out = [];
for (const props of getFiberPropsChain(target)) {
const msg = props?.message;
if (msg && typeof msg === "object" && typeof msg.content === "string") {
out.push(msg);
break;
}
}
const fromDom = messageFromDom(target);
if (fromDom && typeof fromDom === "object" && fromDom !== out[0]) out.push(fromDom);
return out;
}
function emojiNameFromMessages(target, id) {
if (!SNOWFLAKE.test(id)) return void 0;
const inContent = new RegExp(`<a?:(\\w+)(?:~\\d+)?:${id}>`);
for (const msg of messagesNear(target)) {
try {
const m = typeof msg.content === "string" ? inContent.exec(msg.content) : null;
const fromContent = emojiName(m?.[1]);
if (fromContent) return fromContent;
const reactions = Array.isArray(msg.reactions) ? msg.reactions : [];
for (const r of reactions) {
if (String(r?.emoji?.id ?? "") === id) {
const hit = emojiName(r.emoji.name);
if (hit) return hit;
}
}
} catch {
}
}
return void 0;
}
function stickerNameFromMessages(target, id) {
for (const msg of messagesNear(target)) {
try {
const items = Array.isArray(msg.stickerItems) ? msg.stickerItems : Array.isArray(msg.stickers) ? msg.stickers : [];
for (const s of items) {
if (String(s?.id ?? "") === id) {
const hit = stickerName(s.name);
if (hit) return hit;
}
}
} catch {
}
}
return void 0;
}
function emojiNameFromStore(id) {
const store = EmojiStore;
const tries = [
() => store.getCustomEmojiById?.(id),
() => store.getUsableCustomEmojiById?.(id),
() => store.getDisambiguatedEmojiContext?.()?.getById?.(id)
];
for (const attempt of tries) {
try {
const hit = emojiName(attempt()?.name);
if (hit) return hit;
} catch {
}
}
return void 0;
}
var NAME_ATTRS = ["data-name", "alt", "aria-label", "title"];
function emojiNameFromDom(elements) {
for (const el of elements) {
for (const attr of NAME_ATTRS) {
const hit = emojiName(el.getAttribute?.(attr));
if (hit) return hit;
}
}
return void 0;
}
function locate(target) {
const dataEl = target.closest?.("[data-type='emoji'],[data-type='sticker']");
if (dataEl) {
const { id, name, type } = dataEl.dataset;
const img = dataEl.tagName === "IMG" ? dataEl : dataEl.querySelector("img");
if (id && SNOWFLAKE.test(id) && type === "emoji") {
return {
kind: "emoji",
id,
domName: name,
img,
isAnimated: isGifUrl(img?.currentSrc || img?.src)
};
}
if (id && SNOWFLAKE.test(id) && type === "sticker" && !isLottie(dataEl)) {
return { kind: "sticker", id, domName: name, img, isAnimated: false };
}
}
for (const img of gatherImages(target)) {
const src = img.currentSrc || img.src || "";
const emoji = parseEmojiUrl(src);
if (emoji) {
return {
kind: "emoji",
id: emoji.id,
domName: emoji.name,
img,
isAnimated: emoji.isAnimated || isGifUrl(src)
};
}
const sticker = parseStickerUrl(src);
if (sticker) {
if (isLottie(img)) return null;
return { kind: "sticker", id: sticker.id, domName: img.alt, img, isAnimated: false };
}
}
return null;
}
function resolveExpression(target) {
if (!target) return null;
const found = locate(target);
if (!found) return null;
const elements = selfAndAncestors(target);
if (found.img && !elements.includes(found.img)) elements.push(found.img);
if (found.kind === "sticker") {
const record3 = recordFromFiber(target, found.id);
const name = stickerName(record3?.name) ?? stickerNameFromMessages(target, found.id) ?? stickerName(found.domName) ?? stickerName(found.img?.alt);
return { kind: "sticker", id: found.id, name };
}
const record2 = recordFromFiber(target, found.id);
const resolved = emojiName(record2?.name) ?? emojiNameFromMessages(target, found.id) ?? emojiNameFromStore(found.id) ?? emojiNameFromDom(elements) ?? emojiName(found.domName);
if (!resolved) {
log23.warn(`could not resolve this emoji's name; falling back to "emoji"`, { id: found.id });
} else {
log23.debug("resolved emoji", { id: found.id, name: resolved });
}
return {
kind: "emoji",
id: found.id,
name: resolved ?? "emoji",
isAnimated: record2?.animated ?? found.isAnimated
};
}
// src/plugins/emote-cloner/picker.tsx
var log24 = logger("emote-cloner");
function iconUrl(g2) {
const ext = g2.icon && g2.icon.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/icons/${g2.id}/${g2.icon}.${ext}?size=64`;
}
var host3 = null;
var unmount3 = null;
var keyHandler2 = null;
function closeGuildPicker() {
if (keyHandler2) {
document.removeEventListener("keydown", keyHandler2);
keyHandler2 = null;
}
if (unmount3) {
try {
unmount3();
} catch {
}
unmount3 = null;
}
if (host3) {
host3.remove();
host3 = null;
}
}
function openGuildPicker(opts) {
injectStyles();
closeGuildPicker();
host3 = document.createElement("div");
host3.className = "halcyon";
document.body.appendChild(host3);
keyHandler2 = (event) => {
if (event.key === "Escape") closeGuildPicker();
};
document.addEventListener("keydown", keyHandler2);
try {
unmount3 = mountDetached(
React.createElement(PickerModal, {
title: opts.title,
guilds: opts.guilds,
onPick: opts.onPick,
onClose: closeGuildPicker
}),
host3
);
} catch (err) {
log24.error("could not open guild picker", err);
closeGuildPicker();
}
}
function PickerModal({
title,
guilds,
onPick,
onClose
}) {
const [query, setQuery] = useState("");
const [status, setStatus] = useState({ state: "idle" });
const q = query.trim().toLowerCase();
const filtered = q ? guilds.filter((g2) => g2.name.toLowerCase().includes(q)) : guilds;
const pick = (g2) => {
setStatus({ state: "working", guild: g2.name });
Promise.resolve().then(() => onPick(g2.id)).then(() => {
setStatus({ state: "done", guild: g2.name });
setTimeout(onClose, 1e3);
}).catch((err) => {
log24.error("clone failed", err);
setStatus({ state: "error", guild: g2.name, message: err?.message ?? String(err) });
});
};
return /* @__PURE__ */ React.createElement(
"div",
{
className: "hc-overlay",
role: "dialog",
"aria-modal": "true",
"aria-label": title,
onMouseDown: (e) => {
if (e.target === e.currentTarget && status.state !== "working") onClose();
}
},
/* @__PURE__ */ React.createElement("div", { className: "hc-emote-picker" }, /* @__PURE__ */ React.createElement("div", { className: "hc-emote-picker__head" }, /* @__PURE__ */ React.createElement("span", { className: "hc-emote-picker__title" }, title), /* @__PURE__ */ React.createElement(
"button",
{
className: "hc-emote-picker__close",
onClick: onClose,
"aria-label": "\u5173\u95ED",
disabled: status.state === "working"
},
"\u2715"
)), status.state === "idle" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "hc-emote-picker__search" }, /* @__PURE__ */ React.createElement(
"input",
{
className: "hc-input",
placeholder: "\u641C\u7D22\u670D\u52A1\u5668\u2026",
value: query,
autoFocus: true,
onChange: (e) => setQuery(e.currentTarget.value)
}
)), /* @__PURE__ */ React.createElement("div", { className: "hc-emote-picker__list" }, filtered.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "hc-emote-picker__empty" }, guilds.length === 0 ? "\u6CA1\u6709\u53EF\u7BA1\u7406\u8868\u60C5\u7684\u670D\u52A1\u5668" : "\u6CA1\u6709\u5339\u914D\u7684\u670D\u52A1\u5668") : filtered.map((g2) => /* @__PURE__ */ React.createElement(
"div",
{
key: g2.id,
className: "hc-emote-picker__item",
role: "button",
tabIndex: 0,
onClick: () => pick(g2),
onKeyDown: (e) => {
if (e.key === "Enter") pick(g2);
}
},
/* @__PURE__ */ React.createElement("div", { className: "hc-emote-picker__icon" }, g2.icon ? /* @__PURE__ */ React.createElement("img", { src: iconUrl(g2), alt: "" }) : g2.name.charAt(0).toUpperCase()),
/* @__PURE__ */ React.createElement("div", { className: "hc-emote-picker__name" }, g2.name)
)))) : /* @__PURE__ */ React.createElement("div", { className: "hc-emote-picker__status", "data-state": status.state }, /* @__PURE__ */ React.createElement("div", { className: "hc-emote-picker__status-icon" }, status.state === "working" ? "\u23F3" : status.state === "done" ? "\u2713" : "\u2715"), /* @__PURE__ */ React.createElement("div", { className: "hc-emote-picker__status-title" }, status.state === "working" ? `\u6B63\u5728\u590D\u5236\u5230 ${status.guild}\u2026` : status.state === "done" ? `\u5DF2\u590D\u5236\u5230 ${status.guild}` : "\u590D\u5236\u5931\u8D25"), status.state === "error" && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "hc-emote-picker__status-detail" }, status.message), /* @__PURE__ */ React.createElement(
"button",
{
className: "hc-btn hc-btn--secondary hc-btn--sm",
onClick: () => setStatus({ state: "idle" })
},
"\u8FD4\u56DE\u5217\u8868"
))))
);
}
// src/plugins/emote-cloner/index.tsx
var log25 = logger("emote-cloner");
var PERM2 = {
CREATE_GUILD_EXPRESSIONS: 1n << 43n,
MANAGE_GUILD_EXPRESSIONS: 1n << 40n,
MANAGE_EMOJIS_AND_STICKERS: 1n << 30n
};
function canManageExpressions(guild) {
try {
return Boolean(
PermissionStore.can?.(PERM2.CREATE_GUILD_EXPRESSIONS, guild) || PermissionStore.can?.(PERM2.MANAGE_GUILD_EXPRESSIONS, guild) || PermissionStore.can?.(PERM2.MANAGE_EMOJIS_AND_STICKERS, guild)
);
} catch {
return false;
}
}
function eligibleGuilds() {
try {
const map = GuildStore.getGuilds?.() ?? {};
return Object.values(map).filter((g2) => canManageExpressions(g2)).map((g2) => ({
id: String(g2?.id ?? ""),
name: String(g2?.name ?? g2?.id ?? "\u672A\u77E5\u670D\u52A1\u5668"),
icon: g2?.icon ? String(g2.icon) : null
})).filter((g2) => g2.id).sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
} catch {
return [];
}
}
function pickServerAndClone(hit) {
const isEmoji = hit.kind === "emoji";
openGuildPicker({
title: isEmoji ? "\u590D\u5236\u8868\u60C5\u5230\u670D\u52A1\u5668" : "\u590D\u5236\u8D34\u7EB8\u5230\u670D\u52A1\u5668",
guilds: eligibleGuilds(),
onPick: (guildId) => isEmoji ? cloneEmoji(guildId, hit) : cloneSticker(guildId, hit)
});
}
function cloneMenuPatch(children) {
const hit = resolveExpression(getContextMenuTarget());
if (!hit) return;
const MenuItem = getMenuItemComponent();
if (!MenuItem) {
log25.warn("MenuItem component not learned yet; skipping clone item this open");
return;
}
const label = hit.kind === "emoji" ? `\u590D\u5236\u8868\u60C5 :${hit.name}: \u5230\u670D\u52A1\u5668` : hit.name ? `\u590D\u5236\u8D34\u7EB8 ${hit.name} \u5230\u670D\u52A1\u5668` : "\u590D\u5236\u8D34\u7EB8\u5230\u670D\u52A1\u5668";
children.push(
React.createElement(MenuItem, {
id: hit.kind === "emoji" ? "halcyon-clone-emoji" : "halcyon-clone-sticker",
label,
action: () => pickServerAndClone(hit)
})
);
}
var unpatchers2 = [];
var emote_cloner_default = definePlugin({
id: "emote-cloner",
name: "\u8868\u60C5\u514B\u9686",
description: "\u53F3\u952E\u4EFB\u610F\u81EA\u5B9A\u4E49\u8868\u60C5\u6216\u8D34\u7EB8\uFF0C\u5373\u53EF\u628A\u5B83\u590D\u5236\u5230\u4F60\u6709\u7BA1\u7406\u6743\u9650\u7684\u670D\u52A1\u5668\uFF08\u4FDD\u7559\u539F\u540D\uFF09\u3002\u652F\u6301\u6D88\u606F\u91CC\u7684\u8868\u60C5 / \u8868\u60C5\u56DE\u5E94 / \u8D34\u7EB8\uFF0C\u4EE5\u53CA\u8868\u60C5\u9009\u62E9\u5668\u91CC\u7684\u9879\u76EE\u3002",
authors: [{ name: "Vencord" }, { name: "caitemm" }],
category: "utility",
start() {
unpatchers2.push(addContextMenuPatch(["message", "expression-picker"], cloneMenuPatch));
log25.info("emote-cloner ready \u2014 right-click an emoji or sticker");
},
stop() {
for (const un of unpatchers2) {
try {
un();
} catch {
}
}
unpatchers2 = [];
}
});
// src/core/flux/index.ts
var log26 = logger("flux");
var listenersByType = /* @__PURE__ */ new Map();
var dispatcherHandlers = /* @__PURE__ */ new Map();
function dispatcher() {
const d = getDispatcher();
if (!d) log26.error("dispatcher unavailable; flux subscriptions are inert");
return d;
}
function ensureBridge(type) {
if (dispatcherHandlers.has(type)) return;
const handler = (action) => {
const set = listenersByType.get(type);
if (!set) return;
for (const listener of set) {
try {
listener(action);
} catch (err) {
log26.error(`listener for ${type} threw`, err);
}
}
};
const d = dispatcher();
try {
d?.subscribe(type, handler);
dispatcherHandlers.set(type, handler);
} catch (err) {
log26.error(`could not subscribe to ${type}`, err);
}
}
function teardownBridge(type) {
const set = listenersByType.get(type);
if (set && set.size) return;
const handler = dispatcherHandlers.get(type);
if (!handler) return;
try {
dispatcher()?.unsubscribe(type, handler);
} catch (err) {
log26.error(`could not unsubscribe from ${type}`, err);
}
dispatcherHandlers.delete(type);
listenersByType.delete(type);
}
var flux = {
/**
* Listen for a dispatched action by type. Returns an unsubscribe function.
* The callback runs synchronously on dispatch; keep it fast and side-effect free.
*/
subscribe(type, listener) {
let set = listenersByType.get(type);
if (!set) {
set = /* @__PURE__ */ new Set();
listenersByType.set(type, set);
}
set.add(listener);
ensureBridge(type);
let live = true;
return () => {
if (!live) return;
live = false;
set.delete(listener);
teardownBridge(type);
};
},
/** Dispatch an action. Use sparingly; most plugins only ever listen. */
dispatch(action) {
try {
dispatcher()?.dispatch(action);
} catch (err) {
log26.error("dispatch failed", action?.type, err);
}
}
};
// src/plugins/mark-all-read/mark.ts
var log27 = logger("mark-all-read");
var shapeLogged = false;
function channelIdOf(entry) {
return entry?.channel?.id ?? entry?.id;
}
function collectUnread() {
const channels = [];
const guildsWithUnread = /* @__PURE__ */ new Set();
const guilds = GuildStore.getGuilds?.() ?? {};
for (const guildId of Object.keys(guilds)) {
let grouped;
try {
grouped = GuildChannelStore.getChannels?.(guildId);
} catch (err) {
log27.warn(`could not read channels for guild ${guildId}`, err);
continue;
}
if (!grouped) continue;
const ackIfUnread = (id) => {
if (!id) return false;
try {
if (!ReadStateStore.hasUnread?.(id)) return false;
} catch {
return false;
}
channels.push({
channelId: id,
messageId: ReadStateStore.lastMessageId?.(id) ?? null,
readStateType: 0
});
return true;
};
if (!shapeLogged) {
shapeLogged = true;
try {
const desc = Object.keys(grouped).map((k) => {
const v = grouped[k];
if (Array.isArray(v)) return `${k}:array(${v.length})`;
return `${k}:${typeof v}`;
}).join(", ");
log27.info(`getChannels shape for guild ${guildId} \u2014 { ${desc} }`);
for (const k of Object.keys(grouped)) {
const v = grouped[k];
if (Array.isArray(v) && v.length > 0) {
log27.info(` first "${k}" entry keys=[${Object.keys(v[0]).join(",")}]`);
break;
}
}
} catch (err) {
log27.warn("could not describe getChannels shape", err);
}
}
const buckets = [grouped.SELECTABLE, grouped.VOCAL].filter(Array.isArray);
for (const bucket of buckets) {
for (const entry of bucket) {
if (ackIfUnread(channelIdOf(entry))) guildsWithUnread.add(guildId);
}
}
try {
const threadGroups = ActiveJoinedThreadsStore.getActiveJoinedThreadsForGuild?.(guildId);
if (threadGroups && typeof threadGroups === "object") {
for (const group of Object.values(threadGroups)) {
if (!group || typeof group !== "object") continue;
for (const thread of Object.values(group)) {
if (ackIfUnread(thread?.channel?.id ?? thread?.id)) guildsWithUnread.add(guildId);
}
}
}
} catch (err) {
log27.warn(`could not read joined threads for guild ${guildId}`, err);
}
}
return { channels, guilds: guildsWithUnread.size };
}
function diagnoseStores() {
const probe2 = (label, method) => `${label}=${typeof method === "function" ? "ok" : "MISSING"}`;
log27.info(
"store check \u2014 " + [
probe2("GuildStore.getGuilds", GuildStore.getGuilds),
probe2("GuildChannelStore.getChannels", GuildChannelStore.getChannels),
probe2("ReadStateStore.hasUnread", ReadStateStore.hasUnread),
probe2("ReadStateStore.lastMessageId", ReadStateStore.lastMessageId),
probe2(
"ActiveJoinedThreadsStore.getActiveJoinedThreadsForGuild",
ActiveJoinedThreadsStore.getActiveJoinedThreadsForGuild
)
].join(", ")
);
}
function markAllRead() {
diagnoseStores();
const guildCount = Object.keys(GuildStore.getGuilds?.() ?? {}).length;
const { channels, guilds } = collectUnread();
log27.info(`scanned ${guildCount} guild(s); found ${channels.length} unread channel(s)`);
if (channels.length === 0) {
log27.info("nothing unread; skipping BULK_ACK");
return { channels: 0, guilds: 0 };
}
flux.dispatch({
type: "BULK_ACK",
context: "APP",
channels
});
log27.info(`BULK_ACK dispatched for ${channels.length} channel(s) across ${guilds} guild(s)`);
return { channels: channels.length, guilds };
}
// src/plugins/mark-all-read/ui/MarkAllReadPage.tsx
var log28 = logger("mark-all-read");
function MarkAllReadPage() {
const [busy, setBusy] = useState(false);
const [state, setState] = useState("\u5F85\u673A");
const [detail, setDetail] = useState("\u70B9\u51FB\u4E0B\u65B9\u6309\u94AE\uFF0C\u628A\u6240\u6709\u670D\u52A1\u5668\u91CC\u7684\u672A\u8BFB\u4E00\u6B21\u6027\u6E05\u7A7A\u3002");
const onMark = () => {
if (busy) return;
setBusy(true);
setState("\u5904\u7406\u4E2D");
setDetail("\u6B63\u5728\u6536\u96C6\u672A\u8BFB\u9891\u9053\u2026");
try {
const result = markAllRead();
if (result.channels === 0) {
setState("\u5DF2\u662F\u6700\u65B0");
setDetail("\u6CA1\u6709\u627E\u5230\u4EFB\u4F55\u672A\u8BFB\uFF0C\u65E0\u9700\u64CD\u4F5C\u3002");
showToast("\u6CA1\u6709\u672A\u8BFB\u6D88\u606F", "info");
} else {
setState("\u5B8C\u6210");
setDetail(`\u5DF2\u6E05\u7A7A ${result.guilds} \u4E2A\u670D\u52A1\u5668\u4E2D\u7684 ${result.channels} \u4E2A\u9891\u9053\u3002`);
showToast(`\u5DF2\u6807\u8BB0 ${result.channels} \u4E2A\u9891\u9053\u4E3A\u5DF2\u8BFB`, "success");
}
} catch (err) {
setState("\u5931\u8D25");
setDetail(err?.message ?? String(err));
showToast("\u6807\u8BB0\u5931\u8D25", "failure");
log28.error("mark all read failed", err);
} finally {
setBusy(false);
}
};
return /* @__PURE__ */ React.createElement("div", { className: "hc-stack" }, /* @__PURE__ */ React.createElement("div", { className: "hc-inline-note" }, /* @__PURE__ */ React.createElement(InfoIcon, { size: 18 }), /* @__PURE__ */ React.createElement("span", null, "\u4E00\u6B21\u6027\u628A", /* @__PURE__ */ React.createElement("strong", null, "\u6240\u6709\u670D\u52A1\u5668"), "\u7684\u672A\u8BFB\u6D88\u606F\u6807\u4E3A\u5DF2\u8BFB\u3002\u6807\u8BB0\u5DF2\u8BFB\u4E0D\u4F1A\u5220\u9664\u4EFB\u4F55\u6D88\u606F\uFF0C\u4F46\u65E0\u6CD5\u64A4\u9500\u3002")), /* @__PURE__ */ React.createElement(Section, { title: "\u64CD\u4F5C" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cell" }, /* @__PURE__ */ React.createElement(Button, { variant: "primary", icon: /* @__PURE__ */ React.createElement(MessageCheckIcon, { size: 16 }), disabled: busy, onClick: onMark }, "\u5168\u90E8\u6807\u4E3A\u5DF2\u8BFB"))), /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__status" }, /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__status-state" }, state), detail && /* @__PURE__ */ React.createElement("div", { className: "hc-cleaner__status-detail" }, detail)));
}
// src/plugins/mark-all-read/index.tsx
var log29 = logger("mark-all-read");
function runMark() {
try {
const result = markAllRead();
if (result.channels === 0) {
showToast("\u6CA1\u6709\u672A\u8BFB\u6D88\u606F", "info");
} else {
showToast(`\u5DF2\u6807\u8BB0 ${result.channels} \u4E2A\u9891\u9053\u4E3A\u5DF2\u8BFB`, "success");
}
} catch (err) {
showToast("\u6807\u8BB0\u5931\u8D25", "failure");
log29.error("mark all read failed", err);
}
}
function RailButton() {
return /* @__PURE__ */ React.createElement("div", { className: "hc-rail-item" }, /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-rail-btn",
"aria-label": "\u5168\u90E8\u670D\u52A1\u5668\u6807\u4E3A\u5DF2\u8BFB",
title: "\u5168\u90E8\u670D\u52A1\u5668\u6807\u4E3A\u5DF2\u8BFB",
onClick: runMark
},
/* @__PURE__ */ React.createElement(MessageCheckIcon, { size: 24 })
));
}
function QuestRailButton() {
return /* @__PURE__ */ React.createElement("div", { className: "hc-rail-item" }, /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: "hc-rail-btn hc-quest-btn",
"aria-label": "\u4EFB\u52A1\u4E2D\u5FC3",
title: "\u4EFB\u52A1\u4E2D\u5FC3",
onClick: () => {
history.pushState(null, "", "/quest-home");
window.dispatchEvent(new PopStateEvent("popstate"));
}
},
/* @__PURE__ */ React.createElement(QuestIcon, { size: 24 })
));
}
var GUILD_MENUS = ["guild-context", "guild-header-popout"];
var patchGuildMenu = (children) => {
const MenuItem = getMenuItemComponent();
if (!MenuItem) return;
const already = children.some((c) => c?.props?.id === "hc-mark-all-read");
if (already) return;
children.push(
React.createElement(MenuItem, {
id: "hc-mark-all-read",
label: "\u5168\u90E8\u670D\u52A1\u5668\u6807\u4E3A\u5DF2\u8BFB",
action: runMark
})
);
};
var mark_all_read_default = definePlugin({
id: "mark-all-read",
name: "\u4E00\u952E\u5DF2\u8BFB",
description: "\u5728\u670D\u52A1\u5668\u5217\u8868\u7684\u597D\u53CB\u6309\u94AE\u4E0B\u65B9\u52A0\u4E00\u4E2A\u6309\u94AE\uFF0C\u4E00\u952E\u628A\u6240\u6709\u670D\u52A1\u5668\u7684\u672A\u8BFB\u6D88\u606F\u6807\u4E3A\u5DF2\u8BFB\u3002\u4E5F\u53EF\u53F3\u952E\u4EFB\u610F\u670D\u52A1\u5668\uFF0C\u6216\u5728\u672C\u9875\u70B9\u51FB\u3002\u6807\u8BB0\u5DF2\u8BFB\u4E0D\u4F1A\u5220\u9664\u6D88\u606F\uFF0C\u4F46\u65E0\u6CD5\u64A4\u9500\u3002",
authors: [{ name: "caitemm" }, { name: "Vencord" }],
category: "utility",
dependencies: ["context-menu-api"],
// Same target and transform Vencord's ServerListAPI uses for
// ServerListRenderPosition.Above, but anchored on the plain runtime string
// `tutorialId:"friends-list"` instead of Vencord's build-time intl-hash macro
// (which we can't reproduce). It wraps the friends-button element the guild
// nav returns into an array and concatenates our button, letting Discord's own
// SVG layout give it a real slot right after friends.
patches: [
{
label: "read-all-rail-button",
find: 'tutorialId:"friends-list"',
replacement: {
match: /return(\(.{0,200}?tutorialId:"friends-list".+?\}\))(?=\}function)/,
replace: "return[$1].concat($self.renderRailButton())"
}
}
],
/** Called from the patched guild-nav render (via `$self`). Returns the button
* as a keyed single-element array so it slots in right after friends. */
renderRailButton() {
return [
React.createElement(RailButton, { key: "hc-mark-all-read-rail" }),
React.createElement(QuestRailButton, { key: "hc-quest-indicator-rail" })
];
},
page: {
title: "\u4E00\u952E\u5DF2\u8BFB",
icon: MessageCheckIcon,
component: MarkAllReadPage
},
start() {
injectStyles();
addContextMenuPatch(GUILD_MENUS, patchGuildMenu);
log29.info("mark-all-read ready");
},
stop() {
removeContextMenuPatch(GUILD_MENUS, patchGuildMenu);
}
});
// src/plugins/silent-typing/index.ts
var log30 = logger("silent-typing");
var settings7 = defineSettings({
scope: {
group: "\u8303\u56F4",
type: "select",
default: "all",
label: "\u5728\u54EA\u91CC\u9759\u9ED8",
description: "\u53EA\u5728\u90E8\u5206\u573A\u666F\u9690\u85CF\u8F93\u5165\u72B6\u6001\u65F6\uFF0C\u5176\u4F59\u573A\u666F\u4ECD\u6309 Discord \u9ED8\u8BA4\u884C\u4E3A\u53D1\u9001\u3002",
options: [
{ value: "all", label: "\u6240\u6709\u9891\u9053\u4E0E\u79C1\u804A" },
{ value: "guilds", label: "\u53EA\u5728\u670D\u52A1\u5668\u9891\u9053" },
{ value: "dms", label: "\u53EA\u5728\u79C1\u804A / \u7FA4\u804A" }
]
},
allowChannels: {
group: "\u4F8B\u5916",
type: "string-list",
default: [],
label: "\u4F8B\u5916\u9891\u9053 ID",
description: "\u8FD9\u4E9B\u9891\u9053 / \u79C1\u804A\u91CC\u7167\u5E38\u53D1\u9001\u8F93\u5165\u72B6\u6001\u3002\u53F3\u952E\u9891\u9053 \u2192 \u590D\u5236\u9891\u9053 ID\uFF08\u9700\u5148\u5F00\u542F\u5F00\u53D1\u8005\u6A21\u5F0F\uFF09\u3002",
itemPlaceholder: "\u9891\u9053 ID\uFF08\u7EAF\u6570\u5B57\uFF09"
},
silenceStop: {
group: "\u9AD8\u7EA7",
type: "boolean",
default: false,
label: "\u540C\u65F6\u62E6\u622A\u201C\u505C\u6B62\u8F93\u5165\u201D",
description: "\u9ED8\u8BA4\u5173\u95ED\u3002stopTyping \u662F\u7528\u6765\u6E05\u9664\u5DF2\u7ECF\u53D1\u51FA\u53BB\u7684\u8F93\u5165\u72B6\u6001\u7684\uFF0C\u62E6\u622A\u5B83\u53CD\u800C\u53EF\u80FD\u8BA9\u6B8B\u7559\u72B6\u6001\u591A\u6302\u51E0\u79D2\uFF0C\u53EA\u6709\u5728\u4F60\u786E\u8BA4\u4ECE\u4E0D\u53D1\u9001\u65F6\u624D\u9700\u8981\u5F00\u542F\u3002"
}
});
var active = false;
var typingModule;
var unpatchStart;
var unpatchStop;
var suppressed = 0;
function isPrivateChannel(channelId) {
try {
const channel = ChannelStore.getChannel?.(channelId);
if (!channel) return false;
if (typeof channel.isPrivate === "function") return Boolean(channel.isPrivate());
if (channel.guild_id) return false;
return channel.type === 1 || channel.type === 3;
} catch {
return false;
}
}
function silenceFor(channelId) {
if (!active) return false;
const id = channelId == null ? "" : String(channelId);
const s = settings7.store;
if (id && s.allowChannels.includes(id)) return false;
if (s.scope === "guilds") return !isPrivateChannel(id);
if (s.scope === "dms") return isPrivateChannel(id);
return true;
}
function onStartTyping(ctx) {
try {
if (silenceFor(ctx.args[0])) {
suppressed++;
return void 0;
}
} catch (err) {
log30.error("\u5224\u65AD\u662F\u5426\u9759\u9ED8\u65F6\u51FA\u9519\uFF0C\u672C\u6B21\u6309 Discord \u9ED8\u8BA4\u884C\u4E3A\u5904\u7406", err);
}
return ctx.callOriginal();
}
function onStopTyping(ctx) {
try {
if (settings7.store.silenceStop && silenceFor(ctx.args[0])) return void 0;
} catch {
}
return ctx.callOriginal();
}
function clearCurrentTyping() {
try {
const channelId = SelectedChannelStore.getChannelId?.();
if (channelId && typeof typingModule?.stopTyping === "function") {
typingModule.stopTyping(channelId);
}
} catch {
}
}
function reportPatch() {
const mine = getSourcePatchReport().filter((p) => p.pluginId === "silent-typing");
if (mine.length === 0) return;
if (mine.every((p) => p.applied)) {
log30.info("\u6E90\u7801 patch \u5DF2\u751F\u6548\uFF08\u8F93\u5165\u72B6\u6001\u5728\u6E90\u5934\u5C31\u88AB\u62E6\u6389\uFF09");
} else {
log30.warn(
"\u6E90\u7801 patch \u672A\u5339\u914D\u5F53\u524D Discord \u7248\u672C\uFF0C\u5DF2\u6539\u7528\u8FD0\u884C\u65F6 hook \u515C\u5E95\u3002\u82E5\u53D1\u73B0\u522B\u4EBA\u4ECD\u80FD\u770B\u5230\u4F60\u7684\u8F93\u5165\u72B6\u6001\uFF0C\u8BF7\u53CD\u9988\u8FD9\u6761\u65E5\u5FD7\u3002"
);
}
}
var silent_typing_default = definePlugin({
id: "silent-typing",
name: "\u9759\u9ED8\u8F93\u5165",
description: "\u4E0D\u518D\u5411\u522B\u4EBA\u53D1\u9001\u201C\u6B63\u5728\u8F93\u5165\u2026\u201D\u72B6\u6001\u3002\u53EF\u4EE5\u53EA\u5728\u670D\u52A1\u5668\u6216\u53EA\u5728\u79C1\u804A\u751F\u6548\uFF0C\u4E5F\u80FD\u4E3A\u6307\u5B9A\u9891\u9053\u5F00\u4F8B\u5916\u3002\u522B\u4EBA\u7684\u8F93\u5165\u72B6\u6001\u7167\u5E38\u663E\u793A\uFF0C\u5173\u95ED\u63D2\u4EF6\u7ACB\u5373\u6062\u590D\u3002",
authors: [{ name: "Vencord" }, { name: "caitemm" }],
category: "privacy",
settings: settings7,
patches: [
{
// The module that owns Discord's typing actions, identified by the local
// action it dispatches. The replacement is a pure insertion at the top of
// `startTyping`'s body: the whole match is a zero-width lookbehind, so no
// existing character is consumed or moved and the code cannot become
// unbalanced. The `:function` / `async` alternatives cover the shapes
// Discord's minifier emits for the same method across builds.
label: "startTyping guard",
find: '"TYPING_START_LOCAL"',
replacement: {
match: /(?<=\bstartTyping\s*(?:[:=]\s*)?(?:async\s+)?(?:function\s*)?\(\s*(\w+)\s*\)\s*(?:=>\s*)?\{)/,
replace: "if($self.shouldSilence($1))return;"
}
}
],
start() {
suppressed = 0;
active = true;
typingModule = findByProps("startTyping", "stopTyping");
if (!typingModule || typeof typingModule.startTyping !== "function") {
log30.warn(
"\u672A\u627E\u5230 Discord \u7684\u8F93\u5165\u72B6\u6001\u6A21\u5757\uFF08startTyping / stopTyping\uFF09\uFF0C\u8FD0\u884C\u65F6\u515C\u5E95\u4E0D\u53EF\u7528\uFF1B\u4ECD\u4F9D\u8D56\u6E90\u7801 patch\u3002\u6253\u5F00\u4EFB\u610F\u9891\u9053\u540E\u91CD\u65B0\u542F\u7528\u63D2\u4EF6\u53EF\u518D\u8BD5\u4E00\u6B21\u3002"
);
} else {
active = false;
clearCurrentTyping();
active = true;
try {
unpatchStart = patcher.instead(typingModule, "startTyping", onStartTyping);
} catch (err) {
log30.warn("\u6302\u63A5 startTyping \u5931\u8D25\uFF0C\u4EC5\u4F9D\u8D56\u6E90\u7801 patch", err);
}
if (typeof typingModule.stopTyping === "function") {
try {
unpatchStop = patcher.instead(typingModule, "stopTyping", onStopTyping);
} catch (err) {
log30.warn("\u6302\u63A5 stopTyping \u5931\u8D25\uFF0C\u201C\u540C\u65F6\u62E6\u622A\u505C\u6B62\u8F93\u5165\u201D\u5F00\u5173\u5C06\u65E0\u6548", err);
}
}
}
log30.info(`\u5DF2\u62E6\u622A\u8F93\u5165\u72B6\u6001\u4E0A\u62A5\uFF08\u8303\u56F4\uFF1A${settings7.store.scope}\uFF09`);
setTimeout(reportPatch, 4e3);
},
stop() {
active = false;
unpatchStart?.();
unpatchStop?.();
unpatchStart = void 0;
unpatchStop = void 0;
typingModule = void 0;
log30.info(`\u5DF2\u6062\u590D\u8F93\u5165\u72B6\u6001\u4E0A\u62A5\uFF08\u672C\u6B21\u5171\u62E6\u622A ${suppressed} \u6B21\uFF09`);
},
/**
* Called from the source patch at the top of `startTyping`. Returns true to
* abort the call. Guarded end to end: a throw here would break the compose
* box, and the patch stays in the module until the client restarts, so this
* must keep answering sanely even while the plugin is switched off.
*/
shouldSilence(channelId) {
try {
if (!active) return false;
if (silenceFor(channelId)) {
suppressed++;
return true;
}
return false;
} catch {
return false;
}
},
/** Diagnostic snapshot. Surfaced through `HalcyonAPI.probe()`. */
probe() {
const module = typingModule ?? findByProps("startTyping", "stopTyping");
return {
active,
suppressed,
scope: settings7.store.scope,
typingModuleFound: module != null,
startTypingIsFunction: typeof module?.startTyping === "function",
/** True once our wrapper is installed on the module. */
runtimeHookInstalled: unpatchStart != null,
sourcePatches: getSourcePatchReport().filter((p) => p.pluginId === "silent-typing"),
currentChannelWouldBeSilenced: (() => {
try {
return silenceFor(SelectedChannelStore.getChannelId?.());
} catch {
return null;
}
})()
};
}
});
// src/core/dom-probe.ts
function describe(el) {
let box = "n/a";
try {
const r = el.getBoundingClientRect();
box = `${Math.round(r.width)}x${Math.round(r.height)}@${Math.round(r.left)},${Math.round(r.top)}`;
} catch {
}
return {
tag: el.tagName.toLowerCase(),
classes: typeof el.className === "string" ? el.className : String(el.className ?? ""),
childCount: el.children.length,
box
};
}
function probeSelector(selector, limit = 3) {
try {
const found = document.querySelectorAll(selector);
const samples = [];
for (let i = 0; i < found.length && i < limit; i++) samples.push(describe(found[i]));
return { selector, count: found.length, samples };
} catch {
return { selector, count: -1, samples: [] };
}
}
function probeSelectors(selectors, limit = 2) {
return selectors.map((s) => probeSelector(s, limit));
}
function classNamesContaining(needle, limit = 24) {
const out = /* @__PURE__ */ new Set();
try {
const nodes = document.querySelectorAll(`[class*="${needle}"]`);
for (let i = 0; i < nodes.length && out.size < limit; i++) {
const raw = nodes[i].className;
if (typeof raw !== "string") continue;
for (const name of raw.split(/\s+/)) {
if (name.includes(needle)) out.add(name);
if (out.size >= limit) break;
}
}
} catch {
}
return [...out];
}
// src/plugins/member-count/settings.ts
var settings8 = defineSettings({
placement: {
group: "\u4F4D\u7F6E",
type: "select",
default: "header",
label: "\u663E\u793A\u4F4D\u7F6E",
description: "\u9891\u9053\u9876\u680F\u662F\u6A2A\u5411\u5DE5\u5177\u6761\uFF0C\u63D2\u4E00\u4E2A\u5C0F\u6807\u7B7E\u6700\u7A33\uFF0C\u4E5F\u662F Discord \u6CA1\u63D0\u4F9B\u6570\u5B57\u7684\u4F4D\u7F6E\uFF1B\u6210\u5458\u5217\u8868\u9876\u90E8 Discord \u81EA\u5DF1\u5DF2\u7ECF\u663E\u793A\u4E86\u300C\u5728\u7EBF X \xB7 \u5171 Y\u300D\uFF0C\u672C\u63D2\u4EF6\u5728\u90A3\u91CC\u663E\u793A\u53EA\u662F\u8986\u76D6\u540C\u4E00\u4EFD\u4FE1\u606F\uFF0C\u9009\u5B83\u524D\u8BF7\u77E5\u6089\u3002",
options: [
{ value: "header", label: "\u9891\u9053\u9876\u680F" },
{ value: "member-list", label: "\u6210\u5458\u5217\u8868\u9876\u90E8" },
{ value: "both", label: "\u4E24\u5904\u90FD\u663E\u793A" }
]
},
showOnline: {
group: "\u5185\u5BB9",
type: "boolean",
default: true,
label: "\u663E\u793A\u5728\u7EBF\u4EBA\u6570",
description: "\u5728\u7EBF\u4EBA\u6570\u6765\u81EA\u6210\u5458\u5217\u8868\u7684\u5206\u7EC4\u7EDF\u8BA1\uFF0C\u53EA\u6709\u6210\u5458\u5217\u8868\u6253\u5F00\u8FC7\u624D\u6709\u6570\u636E\uFF1B\u62FF\u4E0D\u5230\u65F6\u81EA\u52A8\u9690\u85CF\u3002"
},
showTotal: {
group: "\u5185\u5BB9",
type: "boolean",
default: true,
label: "\u663E\u793A\u603B\u6210\u5458\u6570",
description: "\u670D\u52A1\u5668\u7684\u603B\u6210\u5458\u6570\uFF08\u542B\u79BB\u7EBF\uFF09\u3002"
},
abbreviate: {
group: "\u5185\u5BB9",
type: "boolean",
default: false,
label: "\u7F29\u5199\u5927\u6570\u5B57",
description: "12,345 \u663E\u793A\u4E3A 12.3k\u3002\u5173\u95ED\u5219\u663E\u793A\u5E26\u5343\u4F4D\u5206\u9694\u7684\u5B8C\u6574\u6570\u5B57\u3002"
},
showLabels: {
group: "\u5185\u5BB9",
type: "boolean",
default: true,
label: "\u663E\u793A\u6587\u5B57\u6807\u7B7E",
description: "\u663E\u793A\u201C\u5728\u7EBF / \u5171\u201D\u8FD9\u6837\u7684\u524D\u7F00\u3002\u5173\u95ED\u540E\u53EA\u5269\u6570\u5B57\u4E0E\u5706\u70B9\uFF0C\u66F4\u7D27\u51D1\u3002"
},
preloadCounts: {
group: "\u9AD8\u7EA7",
type: "boolean",
default: true,
label: "\u7F3A\u6570\u636E\u65F6\u8BF7\u6C42\u52A0\u8F7D",
description: "\u5728\u7EBF\u4EBA\u6570\u4F9D\u8D56\u670D\u52A1\u5668\u7684\u6210\u5458\u5217\u8868\u6570\u636E\uFF1B\u5982\u679C\u8FD9\u6B21\u542F\u52A8\u540E\u4ECE\u6CA1\u5C55\u5F00\u8FC7\u6210\u5458\u5217\u8868\uFF0CDiscord \u6839\u672C\u6CA1\u62C9\u8FC7\u8FD9\u4EFD\u6570\u636E\u3002\u5F00\u542F\u540E\uFF0C\u9047\u5230\u7F3A\u6570\u5B57\u7684\u670D\u52A1\u5668\u4F1A\u8C03\u7528 Discord \u81EA\u5DF1\u7684\u9891\u9053\u9884\u52A0\u8F7D\uFF08\u548C\u4F60\u70B9\u8FDB\u670D\u52A1\u5668\u65F6\u4E00\u6837\u7684\u52A8\u4F5C\uFF09\uFF0C\u6BCF\u4E2A\u670D\u52A1\u5668\u6BCF\u6B21\u542F\u52A8\u53EA\u505A\u4E00\u6B21\u3002\u5173\u95ED\u5219\u53EA\u663E\u793A\u5DF2\u6709\u7684\u6570\u5B57\u3002"
}
});
// src/plugins/member-count/counts.ts
var log31 = logger("member-count");
function memo(resolve) {
let cached2;
return () => cached2 ??= resolve();
}
var memberCountStore = memo(
() => findStoreByName("GuildMemberCountStore") ?? findStoreWithMethods("getMemberCount")
);
var channelMemberStore = memo(() => findStoreByName("ChannelMemberStore"));
var channelActions = memo(
() => find((m) => typeof m?.preload === "function" && typeof m?.preloadAllGuilds === "function") ?? find(
(m) => typeof m?.preload === "function" && // Reject Discord's answer-everything intl proxy.
typeof m?.__halcyon_probe__ === "undefined"
)
);
var EMPTY_COUNTS = { total: null, online: null };
function asCount(value) {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
}
function guildIdOfChannel2(channelId) {
if (!channelId) return null;
try {
const channel = ChannelStore.getChannel?.(channelId);
const guildId = channel?.guild_id ?? channel?.getGuildId?.();
return guildId ? String(guildId) : null;
} catch {
return null;
}
}
var onlineByGuild = /* @__PURE__ */ new Map();
var totalByGuild = /* @__PURE__ */ new Map();
var unsubscribes = [];
function sumGroups(groups) {
if (!Array.isArray(groups) || groups.length === 0) return null;
if (groups.length === 1 && groups[0]?.id === "unknown") return null;
let sum = 0;
let sawAny = false;
for (const group of groups) {
if (group?.id === "offline") continue;
const count2 = asCount(group?.count);
if (count2 == null) continue;
sum += count2;
sawAny = true;
}
return sawAny ? sum : null;
}
function startCountTracking() {
stopCountTracking();
const remember2 = (map, guildId, value) => {
const count2 = asCount(value);
if (guildId != null && count2 != null) map.set(String(guildId), count2);
};
unsubscribes = [
flux.subscribe("GUILD_MEMBER_LIST_UPDATE", (action) => {
const a = action;
const sum = sumGroups(a?.groups);
if (a?.guildId != null && sum != null) onlineByGuild.set(String(a.guildId), sum);
remember2(totalByGuild, a?.guildId, a?.memberCount ?? a?.member_count);
}),
flux.subscribe("ONLINE_GUILD_MEMBER_COUNT_UPDATE", (action) => {
remember2(onlineByGuild, action?.guildId, action?.count);
}),
flux.subscribe("GUILD_CREATE", (action) => {
const guild = action?.guild;
remember2(totalByGuild, guild?.id, guild?.member_count ?? guild?.memberCount);
}),
flux.subscribe("GUILD_UPDATE", (action) => {
const guild = action?.guild;
remember2(totalByGuild, guild?.id, guild?.member_count ?? guild?.memberCount);
})
];
}
function stopCountTracking() {
for (const off of unsubscribes) {
try {
off();
} catch {
}
}
unsubscribes = [];
onlineByGuild.clear();
totalByGuild.clear();
nudged.clear();
}
var nudged = /* @__PURE__ */ new Set();
function nudge(guildId, channelId) {
if (!settings8.store.preloadCounts) return;
if (nudged.has(guildId)) return;
nudged.add(guildId);
try {
const api = channelActions();
if (typeof api?.preload !== "function") return;
const target = GuildChannelStore.getDefaultChannel?.(guildId)?.id ?? channelId;
api.preload(guildId, target);
log31.debug(`\u5DF2\u8BF7\u6C42\u52A0\u8F7D ${guildId} \u7684\u6210\u5458\u5217\u8868\u6570\u636E`);
} catch (err) {
log31.debug("preload \u8C03\u7528\u5931\u8D25\uFF0C\u5FFD\u7565", err);
}
}
function readTotal(guildId) {
try {
const fromStore = asCount(memberCountStore()?.getMemberCount?.(guildId));
if (fromStore != null) return fromStore;
} catch {
}
try {
const guild = GuildStore.getGuild?.(guildId);
const fromRecord = asCount(guild?.memberCount) ?? asCount(guild?.approximateMemberCount);
if (fromRecord != null) return fromRecord;
} catch {
}
return totalByGuild.get(guildId) ?? null;
}
function readOnline(guildId, channelId) {
try {
const fromStore = sumGroups(channelMemberStore()?.getProps?.(guildId, channelId)?.groups);
if (fromStore != null) return fromStore;
} catch {
}
return onlineByGuild.get(guildId) ?? null;
}
function readCounts(channelId) {
const guildId = guildIdOfChannel2(channelId);
if (!guildId || !channelId) return EMPTY_COUNTS;
const counts = {
total: readTotal(guildId),
online: readOnline(guildId, String(channelId))
};
if (counts.total == null || counts.online == null) nudge(guildId, String(channelId));
return counts;
}
function countsDiagnostics(channelId) {
const guildId = guildIdOfChannel2(channelId);
const safe = (fn) => {
try {
return fn();
} catch (err) {
return `threw: ${String(err)}`;
}
};
return {
channelId: channelId ?? null,
guildId,
stores: {
memberCountStore: safe(() => memberCountStore()?.getName?.() ?? null),
memberCountStoreHasMethod: safe(() => typeof memberCountStore()?.getMemberCount === "function"),
memberCountRaw: safe(() => guildId ? memberCountStore()?.getMemberCount?.(guildId) : null),
channelMemberStore: safe(() => channelMemberStore()?.getName?.() ?? null),
rawGroups: safe(
() => guildId && channelId ? channelMemberStore()?.getProps?.(guildId, String(channelId))?.groups ?? null : null
),
channelActionsFound: safe(() => typeof channelActions()?.preload === "function")
},
guildRecord: safe(() => {
if (!guildId) return null;
const guild = GuildStore.getGuild?.(guildId);
if (!guild) return null;
return {
memberCount: guild.memberCount ?? null,
approximateMemberCount: guild.approximateMemberCount ?? null,
keys: Object.keys(guild).slice(0, 30)
};
}),
captured: {
total: guildId ? totalByGuild.get(guildId) ?? null : null,
online: guildId ? onlineByGuild.get(guildId) ?? null : null,
trackingActive: unsubscribes.length > 0,
nudged: [...nudged]
},
/** Every store this client registered whose name mentions a count/member. */
storeNameHints: safe(
() => storeNames().filter((n) => /member|count|presence|session/i.test(n))
),
resolved: readCounts(channelId)
};
}
function formatCount(value, abbreviate) {
if (!abbreviate) return value.toLocaleString("en-US");
if (value < 1e3) return String(value);
if (value < 1e6) {
const k = value / 1e3;
return `${k < 10 ? k.toFixed(1) : Math.round(k)}k`;
}
const m = value / 1e6;
return `${m < 10 ? m.toFixed(1) : Math.round(m)}m`;
}
// src/plugins/member-count/ui/MemberCountChip.tsx
var WATCHED_ACTIONS = [
"CHANNEL_SELECT",
"GUILD_MEMBER_LIST_UPDATE",
"GUILD_UPDATE",
"GUILD_CREATE",
"THREAD_MEMBER_LIST_UPDATE"
];
var POLL_MS = 5e3;
function sameCounts(a, b) {
return a.total === b.total && a.online === b.online;
}
function useMemberCounts() {
const [counts, setCounts] = useState(EMPTY_COUNTS);
useEffect(() => {
let live = true;
const refresh = () => {
if (!live) return;
let next;
try {
next = readCounts(SelectedChannelStore.getChannelId?.());
} catch {
next = EMPTY_COUNTS;
}
setCounts((prev) => sameCounts(prev, next) ? prev : next);
};
refresh();
const unsubscribes3 = WATCHED_ACTIONS.map((type) => flux.subscribe(type, refresh));
const timer3 = setInterval(refresh, POLL_MS);
return () => {
live = false;
clearInterval(timer3);
for (const off of unsubscribes3) off();
};
}, []);
return counts;
}
function MemberCountChip({ variant }) {
const { total, online } = useMemberCounts();
const s = settings8.store;
const showOnline = s.showOnline && online != null;
const showTotal = s.showTotal && total != null;
if (!showOnline && !showTotal) return null;
const parts = [];
if (showOnline) parts.push(`\u5728\u7EBF ${online.toLocaleString("en-US")}`);
if (showTotal) parts.push(`\u603B\u6210\u5458 ${total.toLocaleString("en-US")}`);
return /* @__PURE__ */ React.createElement(
"div",
{
className: `hc-membercount hc-membercount--${variant}`,
title: parts.join(" \xB7 "),
"aria-label": parts.join("\uFF0C")
},
/* @__PURE__ */ React.createElement(PeopleIcon, { size: 14, className: "hc-membercount__icon" }),
showOnline && /* @__PURE__ */ React.createElement("span", { className: "hc-membercount__part" }, /* @__PURE__ */ React.createElement("span", { className: "hc-membercount__dot" }), s.showLabels && /* @__PURE__ */ React.createElement("span", { className: "hc-membercount__label" }, "\u5728\u7EBF"), /* @__PURE__ */ React.createElement("span", { className: "hc-membercount__value" }, formatCount(online, s.abbreviate))),
showOnline && showTotal && /* @__PURE__ */ React.createElement("span", { className: "hc-membercount__sep" }, "\xB7"),
showTotal && /* @__PURE__ */ React.createElement("span", { className: "hc-membercount__part" }, s.showLabels && /* @__PURE__ */ React.createElement("span", { className: "hc-membercount__label" }, "\u5171"), /* @__PURE__ */ React.createElement("span", { className: "hc-membercount__value" }, formatCount(total, s.abbreviate)))
);
}
// src/plugins/member-count/index.tsx
var log32 = logger("member-count");
var ANCHORS = {
header: [
'section[class*="title_"] [class*="toolbar_"]',
'section[class*="title"] [class*="toolbar"]',
'[class*="upperContainer"] [class*="toolbar"]',
'[class*="chat_"] [class*="toolbar_"]',
'[class*="toolbar_"]'
],
// Most-specific first. The two "scroller" entries are what the chip should
// actually attach to — inside the member list, above the first group header —
// so the row reads as a natural roster header instead of floating in the
// aside above everything. The outer `membersWrap` / `aside[class*="members"]`
// fallbacks are kept in case a build renames the scroller, but they anchor
// OUTSIDE the scrollable content, which is what produced the "chip floats in
// empty space above the roster" symptom on the current build.
list: [
'[class*="membersWrap"] [class*="members_"]',
'aside[class*="members"] [class*="members_"]',
'[class*="members_"]:not([class*="membersWrap"])',
'[class*="memberList"]',
'[class*="membersWrap"]',
'aside[class*="members"]'
]
};
var ENSURE_MS2 = 1e3;
var mounted2 = /* @__PURE__ */ new Map();
var ensureTimer;
var selfCheckTimer;
var unsubscribePlacement;
var lastSelector = /* @__PURE__ */ new Map();
var warnedNoAnchor = false;
function firstMatch(selectors) {
for (const selector of selectors) {
try {
const el = document.querySelector(selector);
if (el) return { element: el, selector };
} catch {
}
}
return null;
}
function wantedVariants() {
const placement = settings8.store.placement;
const want = /* @__PURE__ */ new Set();
if (placement === "header" || placement === "both") want.add("header");
if (placement === "member-list" || placement === "both") want.add("list");
return want;
}
function teardown2(variant) {
const entry = mounted2.get(variant);
if (!entry) return;
mounted2.delete(variant);
try {
entry.unmount();
} catch {
}
entry.host.remove();
}
function attach2(variant, hit) {
const host5 = document.createElement("div");
host5.className = "hc-membercount-host";
host5.setAttribute("data-hc-plugin", "member-count");
try {
hit.element.insertBefore(host5, hit.element.firstChild);
} catch (err) {
log32.debug(`\u65E0\u6CD5\u5728 ${variant} \u4F4D\u7F6E\u63D2\u5165\u5BBF\u4E3B\u8282\u70B9`, err);
return;
}
try {
const unmount5 = mountDetached(React.createElement(MemberCountChip, { variant }), host5);
mounted2.set(variant, { host: host5, unmount: unmount5, selector: hit.selector });
if (lastSelector.get(variant) !== hit.selector) {
lastSelector.set(variant, hit.selector);
log32.info(`\u5DF2\u6302\u8F7D\u5230 ${variant}\uFF1A${hit.selector}`);
}
} catch (err) {
host5.remove();
log32.error(`\u6302\u8F7D\u6210\u5458\u6570\u6807\u7B7E\u5931\u8D25\uFF08${variant}\uFF09`, err);
}
}
function ensureMounted2() {
const want = wantedVariants();
for (const [variant, entry] of [...mounted2]) {
if (!want.has(variant) || !document.contains(entry.host)) teardown2(variant);
}
let anyAnchor = false;
for (const variant of want) {
if (mounted2.has(variant)) {
anyAnchor = true;
continue;
}
const hit = firstMatch(ANCHORS[variant]);
if (!hit) continue;
anyAnchor = true;
attach2(variant, hit);
}
if (!anyAnchor && !warnedNoAnchor && mounted2.size === 0) {
warnedNoAnchor = true;
log32.warn(
"\u627E\u4E0D\u5230\u53EF\u63D2\u5165\u7684\u4F4D\u7F6E\uFF08\u9891\u9053\u9876\u680F / \u6210\u5458\u5217\u8868\uFF09\u3002\u8BF7\u5148\u6253\u5F00\u4E00\u4E2A\u670D\u52A1\u5668\u9891\u9053\uFF1B\u82E5\u5DF2\u7ECF\u6253\u5F00\u8FD8\u662F\u6CA1\u6709\uFF0C\u5728\u63A7\u5236\u53F0\u8FD0\u884C HalcyonAPI.probe() \u5E76\u628A\u8F93\u51FA\u53D1\u56DE\u6765 \u2014\u2014 \u8BF4\u660E\u8FD9\u4E2A Discord \u7248\u672C\u7684\u5BB9\u5668\u7C7B\u540D\u53D8\u4E86\u3002"
);
}
}
function currentChannelId() {
try {
return SelectedChannelStore.getChannelId?.() ?? null;
} catch {
return null;
}
}
function selfCheck() {
const channelId = currentChannelId();
if (!guildIdOfChannel2(channelId)) return;
const { total, online } = readCounts(channelId);
if (total != null || online != null) return;
log32.warn(
"\u5DF2\u6302\u8F7D\u4F46\u62FF\u4E0D\u5230\u6210\u5458\u6570\uFF08\u6240\u6709\u6570\u636E\u6E90\u90FD\u662F\u7A7A\uFF09\u3002\u4E0B\u9762\u662F\u6BCF\u4E2A\u6765\u6E90\u7684\u5B9E\u9645\u7ED3\u679C\uFF1B\u4E5F\u53EF\u4EE5\u5728\u63A7\u5236\u53F0\u8FD0\u884C HalcyonAPI.probe() \u62FF\u5230\u5B8C\u6574\u62A5\u544A\u3002",
countsDiagnostics(channelId)
);
}
var member_count_default = definePlugin({
id: "member-count",
name: "\u6210\u5458\u6570\u663E\u793A",
description: "\u5728\u9891\u9053\u9876\u680F\u6216\u6210\u5458\u5217\u8868\u9876\u90E8\u663E\u793A\u5F53\u524D\u670D\u52A1\u5668\u7684\u5728\u7EBF\u4EBA\u6570\u4E0E\u603B\u6210\u5458\u6570\u3002\u6570\u5B57\u53D6\u81EA Discord \u81EA\u5DF1\u7684 store\uFF1B\u82E5\u67D0\u670D\u52A1\u5668\u8FD8\u6CA1\u6709\u6210\u5458\u5217\u8868\u6570\u636E\uFF0C\u4F1A\u8C03\u7528\u4E00\u6B21 Discord \u81EA\u8EAB\u7684\u9891\u9053\u9884\u52A0\u8F7D\u6765\u53D6\uFF08\u53EF\u5728\u8BBE\u7F6E\u91CC\u5173\u95ED\uFF09\u3002\u5207\u6362\u670D\u52A1\u5668\u81EA\u52A8\u66F4\u65B0\u3002",
authors: [{ name: "caitemm" }],
category: "utility",
settings: settings8,
start() {
injectStyles();
warnedNoAnchor = false;
lastSelector.clear();
startCountTracking();
ensureMounted2();
ensureTimer = setInterval(ensureMounted2, ENSURE_MS2);
unsubscribePlacement = settings8.subscribe("placement", () => {
warnedNoAnchor = false;
ensureMounted2();
});
selfCheckTimer = setTimeout(selfCheck, 8e3);
log32.info(`\u6210\u5458\u6570\u6807\u7B7E\u5DF2\u542F\u7528\uFF08\u4F4D\u7F6E\uFF1A${settings8.store.placement}\uFF09`);
},
stop() {
if (ensureTimer) {
clearInterval(ensureTimer);
ensureTimer = void 0;
}
if (selfCheckTimer) {
clearTimeout(selfCheckTimer);
selfCheckTimer = void 0;
}
unsubscribePlacement?.();
unsubscribePlacement = void 0;
stopCountTracking();
for (const variant of [...mounted2.keys()]) teardown2(variant);
lastSelector.clear();
log32.info("\u6210\u5458\u6570\u6807\u7B7E\u5DF2\u79FB\u9664");
},
/** Diagnostic snapshot. Surfaced through `HalcyonAPI.probe()`. */
probe() {
const channelId = currentChannelId();
return {
placement: settings8.store.placement,
mounted: [...mounted2.entries()].map(([variant, entry]) => ({
variant,
selector: entry.selector,
attached: document.contains(entry.host),
renderedHtml: entry.host.innerHTML.slice(0, 200)
})),
anchors: {
header: probeSelectors(ANCHORS.header),
list: probeSelectors(ANCHORS.list)
},
classHints: {
toolbar: classNamesContaining("toolbar"),
members: classNamesContaining("members"),
title: classNamesContaining("title_")
},
data: countsDiagnostics(channelId)
};
}
});
// src/plugins/who-reacted/settings.ts
var settings9 = defineSettings({
inlineAvatars: {
group: "\u5E38\u9A7B\u663E\u793A",
type: "boolean",
default: false,
label: "\u76F4\u63A5\u5728\u8868\u60C5\u65C1\u663E\u793A\u5934\u50CF",
description: "\u6BCF\u4E2A\u53CD\u5E94\u5185\u5D4C\u4E00\u5C0F\u884C\u5934\u50CF\u3002\u65B0\u7248 Discord \u684C\u9762\u5BA2\u6237\u7AEF\u5DF2\u7ECF\u539F\u751F\u663E\u793A\uFF0C\u7EDD\u5927\u591A\u6570\u60C5\u51B5\u4E0B\u8FD9\u4E00\u9879\u5E94\u5173\u95ED\uFF1B\u53EA\u6709\u5F53\u4F60\u7684 Discord \u7248\u672C\u6CA1\u6709\u539F\u751F\u7684\u53CD\u5E94\u8005\u5934\u50CF\u9884\u89C8\u65F6\u624D\u5F00\u542F\uFF0C\u5426\u5219\u4F1A\u91CD\u590D\u3002"
},
inlineAvatarCount: {
group: "\u5E38\u9A7B\u663E\u793A",
type: "number",
default: 3,
label: "\u6700\u591A\u663E\u793A\u51E0\u4E2A\u5934\u50CF",
description: "\u53CD\u5E94\u5185\u6700\u591A\u8D34\u51E0\u5F20\u5934\u50CF\u3002\u591A\u4F59\u7684\u4EBA\u4EE5\u300C+N\u300D\u5F62\u5F0F\u6298\u53E0\u3002",
min: 1,
max: 6,
step: 1
},
hoverPopout: {
group: "\u60AC\u505C\u6D6E\u5C42",
type: "boolean",
default: false,
label: "\u60AC\u505C\u65F6\u5F39\u51FA\u5B8C\u6574\u540D\u5355",
description: "\u9F20\u6807\u505C\u5728\u53CD\u5E94\u4E0A\u65F6\u5F39\u51FA\u5B8C\u6574\u53CD\u5E94\u8005\u5217\u8868\uFF08\u5E26\u540D\u5B57\u3001\u53EF\u9009 ID\uFF09\u3002\u5E38\u9A7B\u5934\u50CF\u5DF2\u7ECF\u591F\u7528\u65F6\u53EF\u4EE5\u5173\u6389\u3002"
},
trigger: {
group: "\u60AC\u505C\u6D6E\u5C42",
type: "select",
default: "hover",
label: "\u89E6\u53D1\u65B9\u5F0F",
description: "\u60AC\u505C\u5373\u67E5\u4F1A\u5728\u4F60\u5212\u8FC7\u8868\u60C5\u65F6\u5C31\u8BF7\u6C42\u4E00\u6B21\u540D\u5355\uFF1B\u6309\u4F4F Alt \u60AC\u505C\u66F4\u514B\u5236\uFF0C\u9002\u5408\u4E0D\u60F3\u9891\u7E41\u89E6\u53D1\u7684\u573A\u666F\u3002\u4EC5\u5728\u300C\u60AC\u505C\u65F6\u5F39\u51FA\u5B8C\u6574\u540D\u5355\u300D\u5F00\u542F\u65F6\u751F\u6548\u3002",
options: [
{ value: "hover", label: "\u60AC\u505C\u5373\u67E5" },
{ value: "alt-hover", label: "\u6309\u4F4F Alt \u60AC\u505C" }
]
},
delay: {
group: "\u60AC\u505C\u6D6E\u5C42",
type: "number",
default: 120,
label: "\u60AC\u505C\u5EF6\u8FDF\uFF08\u6BEB\u79D2\uFF09",
description: "\u9F20\u6807\u505C\u7559\u591A\u4E45\u624D\u5F39\u51FA\u540D\u5355\u3002\u592A\u77ED\u4F1A\u5728\u5212\u8FC7\u4E00\u6392\u8868\u60C5\u65F6\u8FDE\u7EED\u53D1\u8BF7\u6C42\u3002\u4EC5\u5728\u300C\u60AC\u505C\u65F6\u5F39\u51FA\u5B8C\u6574\u540D\u5355\u300D\u5F00\u542F\u65F6\u751F\u6548\u3002",
min: 0,
max: 2e3,
step: 50
},
maxUsers: {
group: "\u663E\u793A",
type: "number",
default: 20,
label: "\u6700\u591A\u663E\u793A\u4EBA\u6570",
description: "\u8D85\u51FA\u7684\u90E8\u5206\u6298\u53E0\u4E3A\u201C\u8FD8\u6709 N \u4EBA\u201D\u3002Discord \u5355\u6B21\u6700\u591A\u8FD4\u56DE 100 \u4EBA\u3002",
min: 1,
max: 100,
step: 5
},
showAvatars: {
group: "\u663E\u793A",
type: "boolean",
default: true,
label: "\u663E\u793A\u5934\u50CF",
description: "\u5173\u95ED\u540E\u53EA\u663E\u793A\u540D\u5B57\uFF0C\u4E0D\u4F1A\u52A0\u8F7D\u4EFB\u4F55\u5934\u50CF\u56FE\u7247\u3002"
},
showIds: {
group: "\u663E\u793A",
type: "boolean",
default: false,
label: "\u663E\u793A\u7528\u6237 ID",
description: "\u5728\u540D\u5B57\u540E\u9762\u9644\u4E0A\u7528\u6237 ID\uFF0C\u4FBF\u4E8E\u4E3E\u62A5\u6216\u62C9\u9ED1\u65F6\u590D\u5236\u3002"
}
});
// src/plugins/who-reacted/reactors.ts
var log33 = logger("who-reacted");
var CACHE_TTL_MS = 3e4;
function resolveReaction(node) {
for (const props of getFiberPropsChain(node, 14)) {
const emoji = props?.emoji;
const message = props?.message;
if (emoji == null || message == null) continue;
const messageId = message.id;
const channelId = message.channel_id ?? message.channelId;
if (!messageId || !channelId) continue;
if (!emoji.id && !emoji.name) continue;
return {
channelId: String(channelId),
messageId: String(messageId),
emoji,
count: typeof props.count === "number" ? props.count : null,
type: props.type === 1 ? 1 : 0
};
}
return null;
}
function emojiParam(emoji) {
const name = emoji.name ?? "";
return emoji.id ? `${name}:${emoji.id}` : name;
}
function cacheKey(target) {
return `${target.channelId}/${target.messageId}/${emojiParam(target.emoji)}/${target.type}`;
}
function emojiLabel(emoji) {
if (emoji.id) return `:${emoji.name ?? "emoji"}:`;
return emoji.name ?? "";
}
function defaultAvatar(userId) {
let index = 0;
try {
index = Number((BigInt(userId) >> 22n) % 6n);
} catch {
index = 0;
}
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
}
function avatarUrl(user) {
const id = user?.id ? String(user.id) : null;
if (!id) return null;
const hash = user?.avatar;
if (typeof hash !== "string" || hash.length === 0) return defaultAvatar(id);
const ext = hash.startsWith("a_") ? "gif" : "webp";
return `https://cdn.discordapp.com/avatars/${id}/${hash}.${ext}?size=32`;
}
function toReactor(user) {
const id = user?.id ? String(user.id) : null;
if (!id) return null;
const name = typeof user.global_name === "string" && user.global_name || typeof user.username === "string" && user.username || id;
return { id, name, avatarUrl: avatarUrl(user), bot: user?.bot === true };
}
var cache = /* @__PURE__ */ new Map();
var inFlight = /* @__PURE__ */ new Map();
function cachedReactors(target) {
const entry = cache.get(cacheKey(target));
if (!entry) return null;
if (Date.now() - entry.at > CACHE_TTL_MS) {
cache.delete(cacheKey(target));
return null;
}
return entry.reactors;
}
function clearCache() {
cache.clear();
inFlight.clear();
}
function fetchReactors(target, limit) {
const key = cacheKey(target);
const fresh = cachedReactors(target);
if (fresh) return Promise.resolve(fresh);
const pending = inFlight.get(key);
if (pending) return pending;
const capped = Math.max(1, Math.min(100, Math.trunc(limit) || 20));
const url = `/channels/${target.channelId}/messages/${target.messageId}/reactions/${encodeURIComponent(emojiParam(target.emoji))}?limit=${capped}` + (target.type === 1 ? "&type=1" : "");
const request = (async () => {
const api = RestAPI;
if (typeof api?.get !== "function") {
throw new Error("\u672A\u627E\u5230 Discord \u7684 REST \u6A21\u5757");
}
const response = await api.get({ url, oldFormErrors: true });
const body = response?.body;
if (!Array.isArray(body)) throw new Error("\u8FD4\u56DE\u5185\u5BB9\u4E0D\u662F\u7528\u6237\u5217\u8868");
const reactors = [];
for (const user of body) {
const reactor = toReactor(user);
if (reactor) reactors.push(reactor);
}
cache.set(key, { at: Date.now(), reactors });
return reactors;
})();
const guarded = request.catch((err) => {
log33.debug("\u62C9\u53D6 reaction \u540D\u5355\u5931\u8D25", err);
throw err;
});
inFlight.set(key, guarded);
void guarded.catch(() => void 0).then(() => inFlight.delete(key));
return guarded;
}
// src/plugins/who-reacted/ui/ReactorCard.tsx
function customEmojiUrl(emoji) {
const ext = emoji.animated ? "gif" : "webp";
return `https://cdn.discordapp.com/emojis/${emoji.id}.${ext}?size=32`;
}
function EmojiPreview({ emoji }) {
if (emoji.id) {
return /* @__PURE__ */ React.createElement(
"img",
{
className: "hc-whoreacted__emoji-img",
src: customEmojiUrl(emoji),
alt: emojiLabel(emoji),
width: 18,
height: 18
}
);
}
return /* @__PURE__ */ React.createElement("span", { className: "hc-whoreacted__emoji-char" }, emoji.name ?? "");
}
function ReactorCard({ target }) {
const s = settings9.store;
const [state, setState] = useState(() => {
const cached2 = cachedReactors(target);
return cached2 ? { kind: "ready", reactors: cached2 } : { kind: "loading" };
});
useEffect(() => {
let live = true;
fetchReactors(target, s.maxUsers).then((reactors) => {
if (live) setState({ kind: "ready", reactors });
}).catch((err) => {
if (!live) return;
const message = err instanceof Error ? err.message : typeof err === "string" ? err : "\u672A\u77E5\u9519\u8BEF";
setState({ kind: "error", message });
});
return () => {
live = false;
};
}, []);
const shown = state.kind === "ready" ? state.reactors.slice(0, s.maxUsers) : [];
const total = target.count ?? (state.kind === "ready" ? state.reactors.length : null);
const hidden = state.kind === "ready" && total != null ? Math.max(0, total - shown.length) : 0;
return /* @__PURE__ */ React.createElement("div", { className: "hc-whoreacted" }, /* @__PURE__ */ React.createElement("div", { className: "hc-whoreacted__head" }, /* @__PURE__ */ React.createElement(EmojiPreview, { emoji: target.emoji }), /* @__PURE__ */ React.createElement("span", { className: "hc-whoreacted__title" }, "\u8C01\u70B9\u4E86\u8FD9\u4E2A\u8868\u60C5"), total != null && /* @__PURE__ */ React.createElement("span", { className: "hc-whoreacted__count" }, total)), state.kind === "loading" && /* @__PURE__ */ React.createElement("div", { className: "hc-whoreacted__hint" }, "\u6B63\u5728\u67E5\u8BE2\u2026"), state.kind === "error" && /* @__PURE__ */ React.createElement("div", { className: "hc-whoreacted__hint hc-whoreacted__hint--error" }, "\u67E5\u8BE2\u5931\u8D25\uFF1A", state.message), state.kind === "ready" && shown.length === 0 && /* @__PURE__ */ React.createElement("div", { className: "hc-whoreacted__hint" }, "\u6CA1\u6709\u4EBA\uFF08\u53EF\u80FD\u521A\u521A\u88AB\u53D6\u6D88\uFF09"), shown.length > 0 && /* @__PURE__ */ React.createElement("div", { className: "hc-whoreacted__list" }, shown.map((reactor) => /* @__PURE__ */ React.createElement("div", { className: "hc-whoreacted__row", key: reactor.id }, s.showAvatars && reactor.avatarUrl && /* @__PURE__ */ React.createElement(
"img",
{
className: "hc-whoreacted__avatar",
src: reactor.avatarUrl,
alt: "",
width: 20,
height: 20
}
), /* @__PURE__ */ React.createElement("span", { className: "hc-whoreacted__name" }, reactor.name), reactor.bot && /* @__PURE__ */ React.createElement("span", { className: "hc-whoreacted__tag" }, "BOT"), s.showIds && /* @__PURE__ */ React.createElement("span", { className: "hc-whoreacted__id" }, reactor.id))), hidden > 0 && /* @__PURE__ */ React.createElement("div", { className: "hc-whoreacted__more" }, "\u8FD8\u6709 ", hidden, " \u4EBA")));
}
// src/plugins/who-reacted/inline-avatars.ts
var log34 = logger("who-reacted");
var DECORATED = /* @__PURE__ */ new WeakSet();
var HOST_ATTR = "data-hc-reactors";
var scanTimer;
var mutationObserver;
var REACTION_SELECTOR = '[class*="reactionInner"], [class*="reaction_"]';
function makeHost() {
const host5 = document.createElement("span");
host5.className = "hc-inline-reactors";
host5.setAttribute(HOST_ATTR, "1");
return host5;
}
function fillHost(host5, reactors, totalHint) {
const max = Math.max(1, Math.min(6, Math.trunc(settings9.store.inlineAvatarCount) || 3));
const shown = reactors.slice(0, max);
const total = totalHint ?? reactors.length;
const overflow = Math.max(0, total - shown.length);
host5.textContent = "";
for (const reactor of shown) {
const img = document.createElement("img");
img.className = "hc-inline-reactors__avatar";
if (reactor.avatarUrl) img.src = reactor.avatarUrl;
img.alt = "";
img.loading = "lazy";
img.title = reactor.name;
img.referrerPolicy = "no-referrer";
host5.appendChild(img);
}
if (shown.length > 0 && overflow > 0) {
const more = document.createElement("span");
more.className = "hc-inline-reactors__more";
more.textContent = `+${overflow}`;
host5.appendChild(more);
}
}
function pillHasNativePreview(pill) {
return pill.querySelector('img[src*="cdn.discordapp.com/avatars/"]') != null || pill.querySelector('img[src*="cdn.discordapp.com/embed/avatars/"]') != null;
}
async function decorate(pill) {
if (DECORATED.has(pill)) return;
if (pillHasNativePreview(pill)) {
DECORATED.add(pill);
return;
}
DECORATED.add(pill);
const target = resolveReaction(pill);
if (!target) return;
if (target.count != null && target.count <= 0) return;
const host5 = makeHost();
try {
pill.appendChild(host5);
} catch {
return;
}
try {
const wanted = Math.min(12, Math.max(6, (settings9.store.inlineAvatarCount || 3) + 3));
const reactors = await fetchReactors(target, wanted);
if (!host5.isConnected) return;
if (reactors.length === 0) {
host5.remove();
DECORATED.delete(pill);
return;
}
if (pillHasNativePreview(pill)) {
host5.remove();
return;
}
fillHost(host5, reactors, target.count);
} catch (err) {
log34.debug("inline avatars: fetch failed", err);
host5.remove();
DECORATED.delete(pill);
}
}
function scan() {
if (!settings9.store.inlineAvatars) return;
let pills;
try {
pills = document.querySelectorAll(REACTION_SELECTOR);
} catch {
return;
}
pills.forEach((pill) => {
if (!pill.isConnected) return;
if (DECORATED.has(pill) && !pill.querySelector(`[${HOST_ATTR}]`)) DECORATED.delete(pill);
void decorate(pill);
});
}
function startInlineAvatars() {
if (!settings9.store.inlineAvatars) return;
stopInlineAvatars();
scan();
scanTimer = setInterval(scan, 1500);
if (typeof MutationObserver === "function") {
mutationObserver = new MutationObserver((records) => {
for (const record2 of records) {
record2.addedNodes.forEach((node) => {
if (!(node instanceof Element)) return;
if (node.matches?.(REACTION_SELECTOR)) void decorate(node);
node.querySelectorAll?.(REACTION_SELECTOR).forEach((pill) => void decorate(pill));
});
}
});
try {
mutationObserver.observe(document.body, { childList: true, subtree: true });
} catch {
}
}
log34.info("inline reactor avatars: enabled");
}
function stopInlineAvatars() {
if (scanTimer) {
clearInterval(scanTimer);
scanTimer = void 0;
}
if (mutationObserver) {
try {
mutationObserver.disconnect();
} catch {
}
mutationObserver = void 0;
}
try {
document.querySelectorAll(`[${HOST_ATTR}]`).forEach((host5) => host5.remove());
} catch {
}
}
// src/plugins/who-reacted/index.tsx
var log35 = logger("who-reacted");
var REACTION_SELECTOR2 = '[class*="reactionInner"], [class*="reaction_"]';
var HIDE_GRACE_MS = 140;
var ANCHOR_CHECK_MS = 500;
var host4 = null;
var unmount4 = null;
var anchor = null;
var observer = null;
var anchorTimer;
var hovered = null;
var showTimer;
var hideTimer;
var altDown = false;
var inlineToggleUnsub;
var inlineCountUnsub;
var hoverToggleUnsub;
var hoverListenersAttached = false;
function reposition() {
if (!host4 || !anchor) return;
const rect = anchor.getBoundingClientRect();
const width = host4.offsetWidth || 220;
const height = host4.offsetHeight || 110;
const margin = 8;
let left = rect.left + rect.width / 2 - width / 2;
let top = rect.top - height - margin;
if (top < margin) top = rect.bottom + margin;
left = Math.max(margin, Math.min(left, window.innerWidth - width - margin));
top = Math.max(margin, Math.min(top, window.innerHeight - height - margin));
host4.style.left = `${Math.round(left)}px`;
host4.style.top = `${Math.round(top)}px`;
}
function hide() {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = void 0;
}
if (anchorTimer) {
clearInterval(anchorTimer);
anchorTimer = void 0;
}
if (observer) {
try {
observer.disconnect();
} catch {
}
observer = null;
}
if (unmount4) {
try {
unmount4();
} catch {
}
unmount4 = null;
}
if (host4) {
host4.remove();
host4 = null;
}
anchor = null;
}
function scheduleHide() {
if (!host4 || hideTimer) return;
hideTimer = setTimeout(() => {
hideTimer = void 0;
hide();
}, HIDE_GRACE_MS);
}
function cancelHide() {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = void 0;
}
}
function show(element, target) {
hide();
host4 = document.createElement("div");
host4.className = "halcyon hc-whoreacted-host";
host4.setAttribute("data-hc-plugin", "who-reacted");
document.body.appendChild(host4);
anchor = element;
try {
unmount4 = mountDetached(React.createElement(ReactorCard, { target }), host4);
} catch (err) {
log35.error("\u65E0\u6CD5\u663E\u793A reaction \u540D\u5355", err);
hide();
return;
}
reposition();
if (typeof ResizeObserver === "function") {
observer = new ResizeObserver(() => reposition());
observer.observe(host4);
} else {
setTimeout(reposition, 120);
setTimeout(reposition, 400);
}
anchorTimer = setInterval(() => {
if (!anchor || !document.contains(anchor)) hide();
}, ANCHOR_CHECK_MS);
}
function triggerOpen() {
return settings9.store.trigger !== "alt-hover" || altDown;
}
function tryShow(element) {
if (!triggerOpen()) return;
const target = resolveReaction(element);
if (!target) return;
show(element, target);
}
function clearShowTimer() {
if (showTimer) {
clearTimeout(showTimer);
showTimer = void 0;
}
}
function onMouseOver(event) {
const target = event.target;
if (!(target instanceof Element)) return;
const pill = target.closest(REACTION_SELECTOR2);
if (!pill) {
hovered = null;
clearShowTimer();
scheduleHide();
return;
}
if (pill === hovered) {
cancelHide();
return;
}
hovered = pill;
clearShowTimer();
cancelHide();
const delay = Math.max(0, Math.min(2e3, settings9.store.delay));
showTimer = setTimeout(() => {
showTimer = void 0;
if (hovered === pill && document.contains(pill)) tryShow(pill);
}, delay);
}
function onMouseLeaveWindow() {
hovered = null;
clearShowTimer();
hide();
}
function onKeyDown2(event) {
if (!event.altKey) return;
altDown = true;
if (settings9.store.trigger === "alt-hover" && hovered && !host4) {
if (document.contains(hovered)) tryShow(hovered);
}
}
function onKeyUp(event) {
if (event.key === "Alt" || !event.altKey) {
altDown = false;
if (settings9.store.trigger === "alt-hover") hide();
}
}
function onScrollOrResize() {
if (host4) hide();
}
function onWindowBlur() {
altDown = false;
}
function attachHoverListeners() {
if (hoverListenersAttached) return;
hoverListenersAttached = true;
document.addEventListener("mouseover", onMouseOver, true);
document.addEventListener("mouseleave", onMouseLeaveWindow);
document.addEventListener("keydown", onKeyDown2, true);
document.addEventListener("keyup", onKeyUp, true);
document.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
window.addEventListener("blur", onWindowBlur);
}
function detachHoverListeners() {
if (!hoverListenersAttached) return;
hoverListenersAttached = false;
document.removeEventListener("mouseover", onMouseOver, true);
document.removeEventListener("mouseleave", onMouseLeaveWindow);
document.removeEventListener("keydown", onKeyDown2, true);
document.removeEventListener("keyup", onKeyUp, true);
document.removeEventListener("scroll", onScrollOrResize, true);
window.removeEventListener("resize", onScrollOrResize);
window.removeEventListener("blur", onWindowBlur);
clearShowTimer();
hovered = null;
altDown = false;
hide();
}
var who_reacted_default = definePlugin({
id: "who-reacted",
name: "\u8C01\u70B9\u4E86\u8868\u60C5",
description: "\u5728\u6BCF\u4E2A\u53CD\u5E94\u56DE\u5E94\u5185\u5D4C\u4E00\u5C0F\u884C\u5934\u50CF\uFF08\u524D\u51E0\u4E2A\u53CD\u5E94\u8005\uFF09\uFF0C\u50CF Discord \u684C\u9762\u8FD1\u7248\u7684 Reaction Preview \u4E00\u6837\uFF0C\u4E0D\u7528\u60AC\u505C\u5C31\u770B\u5F97\u5230\u3002\u540D\u5355\u6309\u9700\u67E5\u8BE2\u3001\u7F13\u5B58 30 \u79D2\u3002\u60AC\u505C\u5B8C\u6574\u540D\u5355\u6D6E\u5C42\u9ED8\u8BA4\u5173\u95ED\uFF0C\u9700\u8981\u65F6\u53EF\u5728\u8BBE\u7F6E\u91CC\u5F00\u3002",
authors: [{ name: "Vencord" }, { name: "caitemm" }],
category: "utility",
settings: settings9,
start() {
injectStyles();
clearCache();
startInlineAvatars();
inlineToggleUnsub = settings9.subscribe("inlineAvatars", (on) => {
if (on) startInlineAvatars();
else stopInlineAvatars();
});
inlineCountUnsub = settings9.subscribe("inlineAvatarCount", () => {
stopInlineAvatars();
startInlineAvatars();
});
if (settings9.store.hoverPopout) attachHoverListeners();
hoverToggleUnsub = settings9.subscribe("hoverPopout", (on) => {
if (on) attachHoverListeners();
else detachHoverListeners();
});
log35.info(
`\u5DF2\u542F\u7528\uFF08\u5185\u5D4C\u5934\u50CF\uFF1A${settings9.store.inlineAvatars ? "\u5F00" : "\u5173"}\uFF0C\u60AC\u505C\u6D6E\u5C42\uFF1A${settings9.store.hoverPopout ? "\u5F00" : "\u5173"}\uFF09`
);
},
stop() {
detachHoverListeners();
inlineToggleUnsub?.();
inlineToggleUnsub = void 0;
inlineCountUnsub?.();
inlineCountUnsub = void 0;
hoverToggleUnsub?.();
hoverToggleUnsub = void 0;
stopInlineAvatars();
clearShowTimer();
hovered = null;
altDown = false;
hide();
clearCache();
log35.info("\u5DF2\u505C\u7528");
},
/** Diagnostic snapshot. Surfaced through `HalcyonAPI.probe()`. */
probe() {
let nodes = null;
try {
nodes = document.querySelectorAll(REACTION_SELECTOR2);
} catch {
nodes = null;
}
let sample = null;
if (nodes && nodes.length > 0) {
const target = resolveReaction(nodes[0]);
sample = target ? {
channelId: target.channelId,
messageId: target.messageId,
emoji: { id: target.emoji.id ?? null, name: target.emoji.name ?? null },
count: target.count,
type: target.type
} : "fiber props \u91CC\u6CA1\u6709 message + emoji \u2014\u2014 \u8BF4\u660E\u8FD9\u4E2A\u7248\u672C\u7684 reaction \u7EC4\u4EF6 props \u53D8\u4E86";
}
return {
trigger: settings9.store.trigger,
cardShown: host4 != null,
reactionNodes: nodes?.length ?? -1,
sample,
anchors: probeSelectors([REACTION_SELECTOR2, '[class*="reactionInner"]', '[class*="reaction_"]']),
classHints: classNamesContaining("reaction"),
restApiAvailable: (() => {
try {
return typeof RestAPI?.get === "function";
} catch {
return false;
}
})()
};
}
});
// src/plugins/platform-indicators/platforms.ts
var PresenceStore = lazy((m) => m?.getName?.() === "PresenceStore");
var SessionsStore = lazy((m) => m?.getName?.() === "SessionsStore");
var PLATFORM_ORDER = ["desktop", "mobile", "web", "embedded"];
function normalizeStatus(value) {
return value === "online" || value === "idle" || value === "dnd" ? value : "online";
}
function normalizePlatform(value) {
switch (value) {
case "desktop":
case "mobile":
case "web":
case "embedded":
return value;
default:
return null;
}
}
function currentUserId2() {
try {
const id = UserStore.getCurrentUser?.()?.id;
return typeof id === "string" ? id : null;
} catch {
return null;
}
}
function isBot(userId) {
try {
return UserStore.getUser?.(userId)?.bot === true;
} catch {
return false;
}
}
function fromClientStatuses(userId) {
let entry;
try {
const state = PresenceStore.getState?.();
const map = state?.clientStatuses ?? state?.clientStatus;
entry = map?.[userId];
} catch {
return [];
}
if (entry == null || typeof entry !== "object") return [];
const out = [];
for (const platform of PLATFORM_ORDER) {
const status = entry[platform];
if (status == null) continue;
out.push({ platform, status: normalizeStatus(status) });
}
return out;
}
function fromSessions() {
let sessions;
try {
sessions = SessionsStore.getSessions?.();
} catch {
return [];
}
if (sessions == null || typeof sessions !== "object") return [];
const best = /* @__PURE__ */ new Map();
for (const session of Object.values(sessions)) {
if (session == null || session.sessionId === "all") continue;
const platform = normalizePlatform(session.clientInfo?.client);
if (!platform) continue;
if (!best.has(platform)) best.set(platform, normalizeStatus(session.status));
}
const out = [];
for (const platform of PLATFORM_ORDER) {
const status = best.get(platform);
if (status) out.push({ platform, status });
}
return out;
}
function readPlatforms(userId) {
if (!userId) return [];
if (userId === currentUserId2()) {
const own = fromSessions();
if (own.length) return own;
}
return fromClientStatuses(userId);
}
var COALESCE_MS = 400;
var version = 0;
var scheduled;
var subscribers2 = /* @__PURE__ */ new Set();
function presenceVersion() {
return version;
}
function subscribePresence(listener) {
subscribers2.add(listener);
return () => {
subscribers2.delete(listener);
};
}
function bumpPresence() {
if (scheduled) return;
scheduled = setTimeout(() => {
scheduled = void 0;
version++;
for (const listener of [...subscribers2]) {
try {
listener();
} catch {
}
}
}, COALESCE_MS);
}
function resetPresenceBus() {
if (scheduled) {
clearTimeout(scheduled);
scheduled = void 0;
}
subscribers2.clear();
}
function presenceDiagnostics() {
let presenceStore = false;
let stateKeys = [];
let sampleCount = null;
try {
const state = PresenceStore.getState?.();
presenceStore = state != null && typeof state === "object";
if (presenceStore) stateKeys = Object.keys(state).slice(0, 12);
const map = state?.clientStatuses ?? state?.clientStatus;
sampleCount = map && typeof map === "object" ? Object.keys(map).length : null;
} catch {
presenceStore = false;
}
let sessionsStore = false;
let sessionCount = null;
try {
const sessions = SessionsStore.getSessions?.();
sessionsStore = sessions != null && typeof sessions === "object";
if (sessionsStore) sessionCount = Object.keys(sessions).length;
} catch {
sessionsStore = false;
}
return {
PresenceStore: presenceStore,
presenceStateKeys: stateKeys,
clientStatusesEntries: sampleCount,
SessionsStore: sessionsStore,
sessionCount
};
}
// src/plugins/platform-indicators/settings.ts
var settings10 = defineSettings({
inMessages: {
group: "\u663E\u793A\u4F4D\u7F6E",
type: "boolean",
default: true,
label: "\u6D88\u606F\u4F5C\u8005\u65C1",
description: "\u5728\u804A\u5929\u91CC\u6BCF\u6761\u6D88\u606F\u7684\u7528\u6237\u540D\u540E\u9762\u663E\u793A\u5BF9\u65B9\u6240\u5728\u7684\u5E73\u53F0\u3002"
},
inMemberList: {
group: "\u663E\u793A\u4F4D\u7F6E",
type: "boolean",
default: true,
label: "\u6210\u5458\u5217\u8868",
description: "\u5728\u53F3\u4FA7\u6210\u5458\u5217\u8868\u7684\u6BCF\u4E2A\u540D\u5B57\u540E\u9762\u663E\u793A\u5E73\u53F0\u56FE\u6807\u3002"
},
colorize: {
group: "\u5916\u89C2",
type: "select",
default: "status",
label: "\u56FE\u6807\u914D\u8272",
description: "\u6309\u72B6\u6001\u7740\u8272\u65F6\uFF0C\u7EFF=\u5728\u7EBF\u3001\u9EC4=\u7A7A\u95F2\u3001\u7EA2=\u514D\u6253\u6270\uFF0C\u548C Discord \u7684\u72B6\u6001\u70B9\u4E00\u81F4\u3002",
options: [
{ value: "status", label: "\u6309\u5728\u7EBF\u72B6\u6001\u7740\u8272" },
{ value: "muted", label: "\u7EDF\u4E00\u7070\u8272" }
]
},
iconSize: {
group: "\u5916\u89C2",
type: "select",
default: "14",
label: "\u56FE\u6807\u5927\u5C0F",
options: [
{ value: "12", label: "12\uFF08\u6700\u5C0F\uFF09" },
{ value: "14", label: "14\uFF08\u9ED8\u8BA4\uFF09" },
{ value: "16", label: "16" },
{ value: "18", label: "18" }
]
},
ignoreBots: {
group: "\u8FC7\u6EE4",
type: "boolean",
default: true,
label: "\u5FFD\u7565\u673A\u5668\u4EBA",
description: "\u673A\u5668\u4EBA\u51E0\u4E4E\u603B\u662F\u663E\u793A\u4E3A\u7F51\u9875\u7AEF\uFF0C\u4FE1\u606F\u91CF\u4E3A\u96F6\uFF0C\u9ED8\u8BA4\u4E0D\u663E\u793A\u3002"
},
ignoreSelf: {
group: "\u8FC7\u6EE4",
type: "boolean",
default: false,
label: "\u5FFD\u7565\u81EA\u5DF1",
description: "\u4E0D\u5728\u81EA\u5DF1\u7684\u6D88\u606F\u65C1\u663E\u793A\u5E73\u53F0\u56FE\u6807\u3002"
}
});
// src/plugins/platform-indicators/ui/PlatformIndicator.tsx
var ICONS = {
desktop: DesktopIcon,
mobile: MobileIcon,
web: GlobeIcon,
embedded: GamepadIcon
};
var LABELS = {
desktop: "\u684C\u9762\u5BA2\u6237\u7AEF",
mobile: "\u624B\u673A",
web: "\u7F51\u9875 / \u6D4F\u89C8\u5668",
embedded: "\u6E38\u620F\u4E3B\u673A"
};
var STATUS_LABELS = {
online: "\u5728\u7EBF",
idle: "\u7A7A\u95F2",
dnd: "\u514D\u6253\u6270",
offline: "\u79BB\u7EBF"
};
function usePresenceVersion() {
const [, setVersion] = useState(presenceVersion());
useEffect(() => subscribePresence(() => setVersion(presenceVersion())), []);
return presenceVersion();
}
function PlatformIndicator({
userId,
isSelf
}) {
usePresenceVersion();
const s = settings10.store;
if (s.ignoreSelf && isSelf) return null;
if (s.ignoreBots && isBot(userId)) return null;
const platforms = readPlatforms(userId);
if (platforms.length === 0) return null;
const size = Number(s.iconSize) || 14;
const tone = s.colorize === "status";
return /* @__PURE__ */ React.createElement("span", { className: "hc-platform" }, platforms.map(({ platform, status }) => {
const Icon = ICONS[platform];
const label = `${LABELS[platform]}\uFF08${STATUS_LABELS[status] ?? status}\uFF09`;
return /* @__PURE__ */ React.createElement(
"span",
{
key: platform,
className: `hc-platform__item hc-platform__item--${tone ? status : "muted"}`,
title: label
},
/* @__PURE__ */ React.createElement(Icon, { size, "aria-label": label })
);
}));
}
// src/plugins/platform-indicators/index.tsx
var log36 = logger("platform-indicators");
var MARK = "data-hc-platform";
var MESSAGE_SELECTORS = [
'[id^="message-username-"]',
'[class*="headerText"] [class*="username"]',
'[class*="header_"] [class*="username"]'
];
var MEMBER_SELECTORS = [
'[class*="membersWrap"] [class*="nameAndDecorators"]',
'[class*="members"] [class*="nameAndDecorators"]',
'[class*="nameAndDecorators"]',
'[class*="membersWrap"] [class*="memberInner"]',
'[class*="member_"] [class*="username"]'
];
var WATCHED_ACTIONS2 = [
"PRESENCE_UPDATES",
"PRESENCE_UPDATE",
"SESSIONS_REPLACE",
"GUILD_MEMBER_LIST_UPDATE"
];
var SCAN_MS = 1e3;
var mounted3 = /* @__PURE__ */ new Map();
var scanTimer2;
var unsubscribes2 = [];
function currentUserId3() {
try {
const id = UserStore.getCurrentUser?.()?.id;
return typeof id === "string" ? id : null;
} catch {
return null;
}
}
function resolveUserId(node, kind) {
const chain = getFiberPropsChain(node, 16);
if (kind === "message") {
for (const props of chain) {
const id = props?.message?.author?.id;
if (id) return String(id);
}
}
for (const props of chain) {
const id = props?.user?.id;
if (id) return String(id);
}
for (const props of chain) {
const id = props?.message?.author?.id;
if (id) return String(id);
}
return null;
}
function attach3(anchor2, kind, userId, selfId) {
const host5 = document.createElement("span");
host5.className = "hc-platform-host";
host5.setAttribute("data-hc-plugin", "platform-indicators");
try {
anchor2.appendChild(host5);
} catch {
return false;
}
try {
const unmount5 = mountDetached(
React.createElement(PlatformIndicator, { userId, isSelf: userId === selfId }),
host5
);
mounted3.set(host5, { kind, host: host5, anchor: anchor2, unmount: unmount5 });
return true;
} catch (err) {
host5.remove();
log36.debug("\u6302\u8F7D\u5E73\u53F0\u56FE\u6807\u5931\u8D25", err);
return false;
}
}
function mountInto(nodes, kind, selfId) {
for (let i = 0; i < nodes.length; i++) {
const anchor2 = nodes[i];
if (anchor2.hasAttribute(MARK)) continue;
const userId = resolveUserId(anchor2, kind);
if (!userId) {
anchor2.setAttribute(MARK, "0");
continue;
}
anchor2.setAttribute(MARK, kind);
if (!attach3(anchor2, kind, userId, selfId)) anchor2.removeAttribute(MARK);
}
}
function detach(entry) {
mounted3.delete(entry.host);
try {
entry.unmount();
} catch {
}
entry.host.remove();
try {
entry.anchor.removeAttribute(MARK);
} catch {
}
}
function prune() {
for (const entry of [...mounted3.values()]) {
if (!document.contains(entry.host)) detach(entry);
}
}
function detachKind(kind) {
for (const entry of [...mounted3.values()]) {
if (entry.kind === kind) detach(entry);
}
}
function firstMatchAll(selectors) {
for (const selector of selectors) {
try {
const nodes = document.querySelectorAll(selector);
if (nodes.length > 0) return { nodes, selector };
} catch {
}
}
return null;
}
var lastSelector2 = /* @__PURE__ */ new Map();
var warnedNoAnchor2 = false;
function scanKind(kind, selectors, selfId) {
const hit = firstMatchAll(selectors);
if (!hit) return false;
if (lastSelector2.get(kind) !== hit.selector) {
lastSelector2.set(kind, hit.selector);
log36.info(`${kind} \u951A\u70B9\uFF1A${hit.selector}\uFF08${hit.nodes.length} \u4E2A\uFF09`);
}
mountInto(hit.nodes, kind, selfId);
return true;
}
function scan2() {
prune();
const s = settings10.store;
const selfId = currentUserId3();
let anyAnchor = false;
if (s.inMessages && scanKind("message", MESSAGE_SELECTORS, selfId)) anyAnchor = true;
if (s.inMemberList && scanKind("member", MEMBER_SELECTORS, selfId)) anyAnchor = true;
if (!anyAnchor && !warnedNoAnchor2 && (s.inMessages || s.inMemberList)) {
warnedNoAnchor2 = true;
log36.warn(
"\u627E\u4E0D\u5230\u53EF\u6302\u8F7D\u7684\u4F4D\u7F6E\uFF08\u6D88\u606F\u4F5C\u8005 / \u6210\u5458\u5217\u8868\uFF09\u3002\u8BF7\u5148\u6253\u5F00\u4E00\u4E2A\u6709\u6D88\u606F\u7684\u9891\u9053\uFF1B\u82E5\u5DF2\u7ECF\u6253\u5F00\u8FD8\u662F\u6CA1\u6709\uFF0C\u5728\u63A7\u5236\u53F0\u8FD0\u884C HalcyonAPI.probe() \u5E76\u628A\u8F93\u51FA\u53D1\u56DE\u6765\u3002"
);
}
}
function clearMarks() {
try {
for (const node of document.querySelectorAll(`[${MARK}]`)) {
node.removeAttribute(MARK);
}
} catch {
}
}
var platform_indicators_default = definePlugin({
id: "platform-indicators",
name: "\u5E73\u53F0\u6807\u8BC6",
description: "\u5728\u6D88\u606F\u4F5C\u8005\u4E0E\u6210\u5458\u5217\u8868\u65C1\u663E\u793A\u5BF9\u65B9\u5F53\u524D\u6240\u5728\u7684\u5E73\u53F0\uFF08\u684C\u9762\u7AEF / \u624B\u673A / \u7F51\u9875 / \u6E38\u620F\u4E3B\u673A\uFF09\uFF0C\u56FE\u6807\u6309\u5728\u7EBF\u72B6\u6001\u7740\u8272\u3002\u6570\u636E\u53D6\u81EA Discord \u81EA\u5DF1\u7684\u72B6\u6001 store\uFF0C\u4E0D\u53D1\u4EFB\u4F55\u8BF7\u6C42\u3002",
authors: [{ name: "Vencord" }, { name: "caitemm" }],
category: "appearance",
settings: settings10,
start() {
injectStyles();
warnedNoAnchor2 = false;
lastSelector2.clear();
scan2();
scanTimer2 = setInterval(scan2, SCAN_MS);
unsubscribes2 = WATCHED_ACTIONS2.map((type) => flux.subscribe(type, bumpPresence));
unsubscribes2.push(
settings10.subscribe("inMessages", (on) => {
if (!on) detachKind("message");
else scan2();
}),
settings10.subscribe("inMemberList", (on) => {
if (!on) detachKind("member");
else scan2();
}),
settings10.subscribe("colorize", () => bumpPresence()),
settings10.subscribe("iconSize", () => bumpPresence()),
settings10.subscribe("ignoreBots", () => bumpPresence()),
settings10.subscribe("ignoreSelf", () => bumpPresence())
);
log36.info("\u5E73\u53F0\u6807\u8BC6\u5DF2\u542F\u7528");
},
stop() {
if (scanTimer2) {
clearInterval(scanTimer2);
scanTimer2 = void 0;
}
for (const off of unsubscribes2) {
try {
off();
} catch {
}
}
unsubscribes2 = [];
for (const entry of [...mounted3.values()]) detach(entry);
clearMarks();
resetPresenceBus();
lastSelector2.clear();
log36.info("\u5E73\u53F0\u6807\u8BC6\u5DF2\u79FB\u9664");
},
/** Diagnostic snapshot. Surfaced through `HalcyonAPI.probe()`. */
probe() {
const selfId = currentUserId3();
const messageHit = firstMatchAll(MESSAGE_SELECTORS);
const memberHit = firstMatchAll(MEMBER_SELECTORS);
const sample = (hit, kind) => {
if (!hit || hit.nodes.length === 0) return null;
const node = hit.nodes[0];
const userId = resolveUserId(node, kind);
return {
selector: hit.selector,
matches: hit.nodes.length,
userId,
platforms: userId ? readPlatforms(userId) : null,
isBot: userId ? isBot(userId) : null
};
};
return {
settings: {
inMessages: settings10.store.inMessages,
inMemberList: settings10.store.inMemberList,
ignoreBots: settings10.store.ignoreBots
},
mountedCount: mounted3.size,
selfId,
selfPlatforms: selfId ? readPlatforms(selfId) : null,
message: sample(messageHit, "message"),
member: sample(memberHit, "member"),
anchors: {
message: probeSelectors(MESSAGE_SELECTORS),
member: probeSelectors(MEMBER_SELECTORS)
},
classHints: {
username: classNamesContaining("username"),
nameAndDecorators: classNamesContaining("nameAndDecorators")
},
stores: presenceDiagnostics()
};
}
});
// src/plugins/index.ts
var plugins = [
settings_host_default,
context_menu_api_default,
message_logger_default,
show_username_default,
guild_monitor_default,
message_cleaner_default,
fake_nitro_default,
console_cleaner_default,
emote_cloner_default,
mark_all_read_default,
silent_typing_default,
member_count_default,
who_reacted_default,
platform_indicators_default
];
// src/core/probe.ts
var log37 = logger("probe");
function probe() {
const perPlugin = {};
for (const view of runtime.list()) {
const plugin = runtime.getPlugin(view.id);
const fn = plugin?.probe;
if (typeof fn !== "function") continue;
try {
perPlugin[view.id] = {
enabled: view.enabled,
state: view.state,
needsRestart: view.needsRestart,
report: fn.call(plugin)
};
} catch (err) {
perPlugin[view.id] = {
enabled: view.enabled,
state: view.state,
probeError: String(err)
};
}
}
const out = {
version: true ? "0.6.1" : "dev",
build: true ? "2026-07-28 06:19:35" : "dev",
href: (() => {
try {
return location.pathname;
} catch {
return null;
}
})(),
plugins: perPlugin,
patches: getSourcePatchReport()
};
try {
globalThis.__halcyonProbe = JSON.stringify(out, null, 2);
log37.info("probe \u5DF2\u751F\u6210 \u2014\u2014 \u5728\u63A7\u5236\u53F0\u8FD0\u884C copy(__halcyonProbe) \u7136\u540E\u628A\u5185\u5BB9\u8D34\u56DE\u6765");
} catch {
}
return out;
}
// src/userscript/main.ts
var log38 = logger("userscript");
runtime.registerAll(plugins);
runtime.boot().then(() => {
injectStyles();
try {
globalThis.HalcyonAPI = {
open: openSettings,
close: closeSettings,
runtime,
patchReport: () => getSourcePatchReport(),
dumpSource: (needle, radius) => dumpFactorySource(needle, radius),
diagnose: () => diagnoseSettings(),
probe
};
} catch {
}
log38.info("Halcyon (userscript) ready \u2014 press Ctrl/Cmd+Shift+H to open settings");
}).catch((err) => log38.error("userscript boot failed", err));
})();