From 2022739bf72d3f1ce2c71bd8d2aee4c6ee7516f1 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:08:19 -0400 Subject: [PATCH 01/82] add discord-embed-router dependency, vitest, and @routes path alias Co-Authored-By: Claude Fable 5 --- package-lock.json | 1331 +++++++++++++++++++++++++++++++++++++++++++-- package.json | 5 +- tsconfig.json | 5 +- vitest.config.mts | 21 + 4 files changed, 1300 insertions(+), 62 deletions(-) create mode 100644 vitest.config.mts diff --git a/package-lock.json b/package-lock.json index 2727239..82b4ab5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@supabase/supabase-js": "^2.110.2", + "discord-embed-router": "^1.0.0", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", @@ -25,7 +26,8 @@ "tsup": "^8.5.0", "tsx": "^4.20.5", "typescript": "^5.9.2", - "typescript-eslint": "^8.44.0" + "typescript-eslint": "^8.44.0", + "vitest": "^4.1.10" } }, "node_modules/@babel/code-frame": { @@ -155,15 +157,15 @@ } }, "node_modules/@discordjs/builders": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.11.3.tgz", - "integrity": "sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "license": "Apache-2.0", "dependencies": { - "@discordjs/formatters": "^0.6.1", - "@discordjs/util": "^1.1.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.16", + "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -184,12 +186,12 @@ } }, "node_modules/@discordjs/formatters": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.1.tgz", - "integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", "license": "Apache-2.0", "dependencies": { - "discord-api-types": "^0.38.1" + "discord-api-types": "^0.38.33" }, "engines": { "node": ">=16.11.0" @@ -199,20 +201,20 @@ } }, "node_modules/@discordjs/rest": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", - "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", + "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.16", - "magic-bytes.js": "^1.10.0", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -233,11 +235,24 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@discordjs/util": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", - "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, "engines": { "node": ">=18" }, @@ -295,6 +310,40 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -1113,6 +1162,25 @@ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -1193,6 +1261,16 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1204,6 +1282,288 @@ "node": ">=14" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.52.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.0.tgz", @@ -1556,6 +1916,13 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.110.2", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.2.tgz", @@ -1775,12 +2142,41 @@ "node": ">=14.16" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", @@ -2047,6 +2443,92 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vladfrangu/async_event_emitter": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz", @@ -2150,6 +2632,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2241,6 +2733,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2375,6 +2877,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2447,34 +2956,62 @@ "node": ">=10" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/discord-api-types": { - "version": "0.38.26", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.26.tgz", - "integrity": "sha512-xpmPviHjIJ6dFu1eNwNDIGQ3N6qmPUUYFVAx/YZ64h7ZgPkTcKjnciD8bZe8Vbeji7yS5uYljyciunpq0J5NSw==", + "version": "0.38.49", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", + "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" ] }, + "node_modules/discord-embed-router": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.0.0.tgz", + "integrity": "sha512-I//lj6D6/EB+GgjE38OC2t/5m5C8i6HMEKmDRAmNg6j7wWPZ/GLNd+UYYfdNHRzZJm7QkDrXigP5rOl/MGrcGA==", + "license": "MIT", + "workspaces": [ + "examples/basic-bot" + ], + "dependencies": { + "path-to-regexp": "^8.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "discord.js": "^14.26.4" + } + }, "node_modules/discord.js": { - "version": "14.22.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.22.1.tgz", - "integrity": "sha512-3k+Kisd/v570Jr68A1kNs7qVhNehDwDJAPe4DZ2Syt+/zobf9zEcuYFvsfIaAOgCa0BiHMfOOKQY4eYINl0z7w==", + "version": "14.26.5", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.5.tgz", + "integrity": "sha512-SGYgjiAs0o8ZzMC97XmFXKONyairJ9YzVda+LvoSKs9YYh2gPtQhY+liaV6H/w72jxYbu9ggiSqAXoF3FLOH4A==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.11.2", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.1", - "@discordjs/rest": "^2.6.0", - "@discordjs/util": "^1.1.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.16", + "discord-api-types": "^0.38.48", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -2549,6 +3086,13 @@ "dev": true, "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -2806,6 +3350,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2816,6 +3370,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3398,6 +3962,279 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3444,9 +4281,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -3486,15 +4323,15 @@ "license": "ISC" }, "node_modules/magic-bytes.js": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", - "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3607,6 +4444,25 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3655,6 +4511,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3806,6 +4676,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3821,9 +4701,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -3855,6 +4735,35 @@ "pathe": "^2.0.1" } }, + "node_modules/postcss": { + "version": "8.5.17", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz", + "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -4044,6 +4953,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/rollup": { "version": "4.52.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.0.tgz", @@ -4144,6 +5087,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -4171,6 +5121,30 @@ "node": ">= 8" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -4413,6 +5387,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -4421,14 +5402,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -4437,6 +5418,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4643,9 +5634,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -4667,6 +5658,211 @@ "punycode": "^2.1.0" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -4701,6 +5897,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index a655051..a3847a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "scripts": { "lint": "npx eslint --config src/eslint.config.mts src", + "test": "vitest run", "typecheck": "tsc --noEmit", "dev": "tsx watch src/index.ts", "deploy-commands": "tsx src/deploy-commands.ts", @@ -13,6 +14,7 @@ }, "dependencies": { "@supabase/supabase-js": "^2.110.2", + "discord-embed-router": "^1.0.0", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", @@ -32,6 +34,7 @@ "tsup": "^8.5.0", "tsx": "^4.20.5", "typescript": "^5.9.2", - "typescript-eslint": "^8.44.0" + "typescript-eslint": "^8.44.0", + "vitest": "^4.1.10" } } diff --git a/tsconfig.json b/tsconfig.json index 7ddbf69..c674877 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ // Visit https://aka.ms/tsconfig to read more about this file // bundling is done by tsup; tsc is only used for typechecking (no emit), // so there is no rootDir and scripts/ is typechecked alongside src/ - "include": ["src", "scripts"], + "include": ["src", "scripts", "vitest.config.mts"], "compilerOptions": { // File Layout "outDir": "./dist", @@ -10,7 +10,8 @@ "@config": ["./src/config"], "@commands/*": ["./src/commands/*"], "@db/*": ["./src/main/db/*"], - "@main/*": ["./src/main/*"] + "@main/*": ["./src/main/*"], + "@routes/*": ["./src/routes/*"] }, // Environment Settings diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..424b024 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,21 @@ +import { resolve } from "node:path"; + +import { defineConfig } from "vitest/config"; + +// mirrors the path aliases in tsconfig.json +const src = (subpath: string) => resolve(import.meta.dirname, "src", subpath); + +export default defineConfig({ + resolve: { + alias: [ + { find: /^@config$/, replacement: src("config.ts") }, + { find: /^@commands\//, replacement: src("commands") + "/" }, + { find: /^@db\//, replacement: src("main/db") + "/" }, + { find: /^@main\//, replacement: src("main") + "/" }, + { find: /^@routes\//, replacement: src("routes") + "/" }, + ], + }, + test: { + include: ["src/**/*.test.ts"], + }, +}); From 9bcdfded1110ea5e548a2703c247e24acc3b34a9 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:08:19 -0400 Subject: [PATCH 02/82] add shared route helpers: paged diff editor, flash notices, router types Co-Authored-By: Claude Fable 5 --- src/routes/lib/flash.test.ts | 33 +++++++++++++ src/routes/lib/flash.ts | 34 ++++++++++++++ src/routes/lib/paging.test.ts | 87 +++++++++++++++++++++++++++++++++++ src/routes/lib/paging.ts | 44 ++++++++++++++++++ src/routes/types.ts | 29 ++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 src/routes/lib/flash.test.ts create mode 100644 src/routes/lib/flash.ts create mode 100644 src/routes/lib/paging.test.ts create mode 100644 src/routes/lib/paging.ts create mode 100644 src/routes/types.ts diff --git a/src/routes/lib/flash.test.ts b/src/routes/lib/flash.test.ts new file mode 100644 index 0000000..e50e168 --- /dev/null +++ b/src/routes/lib/flash.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "vitest"; + +import { flashLine, flashRedirect } from "@routes/lib/flash"; + +test("flashRedirect carries the flash text and level as redirect query params", () => { + expect(flashRedirect("/filter/global", "Blocked <@1>", "success")).toStrictEqual({ + redirect: "/filter/global", + queryParams: { flash: "Blocked <@1>", level: "success" }, + }); +}); + +test("flashRedirect merges extra params like the page to stay on", () => { + expect( + flashRedirect("/signups", "Signed up", "success", { page: "2" }), + ).toStrictEqual({ + redirect: "/signups", + queryParams: { flash: "Signed up", level: "success", page: "2" }, + }); +}); + +test("flashLine renders the flash verbatim with an icon from the level alone", () => { + expect(flashLine(new URLSearchParams({ flash: "Done", level: "success" }))).toBe( + "✅ Done", + ); + expect(flashLine(new URLSearchParams({ flash: "Careful", level: "warn" }))).toBe( + "⚠️ Careful", + ); +}); + +test("flashLine returns null when no flash is present", () => { + expect(flashLine(new URLSearchParams())).toBeNull(); + expect(flashLine(new URLSearchParams({ flash: "" }))).toBeNull(); +}); diff --git a/src/routes/lib/flash.ts b/src/routes/lib/flash.ts new file mode 100644 index 0000000..1df07d4 --- /dev/null +++ b/src/routes/lib/flash.ts @@ -0,0 +1,34 @@ +import { RouteRedirect } from "discord-embed-router"; + +export type FlashLevel = "success" | "warn"; + +// the redirect a mutation handler returns so its outcome shows as a notice +// on the target panel. The flash lives only in the in-flight dispatch: the +// target GET receives it through state.queryParams and the components it +// builds don't carry it, so the notice clears on the next interaction +export const flashRedirect = ( + redirect: string, + flash: string, + level: FlashLevel, + extraParams: Record = {}, +): RouteRedirect => ({ + redirect, + queryParams: { flash, level, ...extraParams }, +}); + +// the notice line a panel renders at the top of its embed; the flash text is +// shown verbatim, only the level picks the icon +export const flashLine = (queryParams: URLSearchParams): string | null => { + const flash = queryParams.get("flash"); + if (!flash) return null; + return `${queryParams.get("level") === "warn" ? "⚠️" : "✅"} ${flash}`; +}; + +// an embed description with the flash notice, when present, above the body +export const withFlash = ( + queryParams: URLSearchParams, + body: string, +): string => { + const line = flashLine(queryParams); + return line ? `${line}\n\n${body}` : body; +}; diff --git a/src/routes/lib/paging.test.ts b/src/routes/lib/paging.test.ts new file mode 100644 index 0000000..6f4b491 --- /dev/null +++ b/src/routes/lib/paging.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "vitest"; + +import { diffSelection, paginate } from "@routes/lib/paging"; + +const ids = (count: number, offset = 0) => + Array.from({ length: count }, (_, i) => `${i + offset + 1}`); + +test("lists of 25 or fewer fit one page with no pagination", () => { + expect(paginate(ids(25), null)).toStrictEqual({ + pageItems: ids(25), + page: 0, + pageCount: 1, + }); +}); + +test("lists over 25 split into pages of 25", () => { + const items = ids(30); + expect(paginate(items, "1")).toStrictEqual({ + pageItems: items.slice(25), + page: 1, + pageCount: 2, + }); +}); + +test("a full last page is followed by an empty page for adding", () => { + const items = ids(50); + expect(paginate(items, "2")).toStrictEqual({ + pageItems: [], + page: 2, + pageCount: 3, + }); +}); + +test("a stale page index clamps to the last page", () => { + const items = ids(30); + expect(paginate(items, "7").page).toBe(1); +}); + +test("garbage and negative page indexes fall back to the first page", () => { + expect(paginate(ids(30), "abc").page).toBe(0); + expect(paginate(ids(30), "-3").page).toBe(0); +}); + +test("selecting new values adds them without touching other pages", () => { + const allItems = ids(30); + const { pageItems } = paginate(allItems, "1"); + expect( + diffSelection({ allItems, pageItems, submitted: [...pageItems, "999"] }), + ).toStrictEqual({ added: ["999"], removed: [] }); +}); + +test("deselecting page entries removes only them", () => { + const allItems = ids(30); + const { pageItems } = paginate(allItems, "1"); + expect( + diffSelection({ allItems, pageItems, submitted: pageItems.slice(1) }), + ).toStrictEqual({ added: [], removed: [pageItems[0]] }); +}); + +test("adds and removes apply together from one submission", () => { + const allItems = ids(30); + const { pageItems } = paginate(allItems, "1"); + expect( + diffSelection({ + allItems, + pageItems, + submitted: [...pageItems.slice(1), "999"], + }), + ).toStrictEqual({ added: ["999"], removed: [pageItems[0]] }); +}); + +test("a submitted value that already lives on another page is not re-added", () => { + const allItems = ids(30); + const { pageItems } = paginate(allItems, "1"); + // "1" is on page 0; selecting it on page 1 must not report an add + expect( + diffSelection({ allItems, pageItems, submitted: [...pageItems, "1"] }), + ).toStrictEqual({ added: [], removed: [] }); +}); + +test("deselecting everything on a page removes the whole page only", () => { + const allItems = ids(30); + const { pageItems } = paginate(allItems, "0"); + expect( + diffSelection({ allItems, pageItems, submitted: [] }), + ).toStrictEqual({ added: [], removed: pageItems }); +}); diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts new file mode 100644 index 0000000..840e69d --- /dev/null +++ b/src/routes/lib/paging.ts @@ -0,0 +1,44 @@ +// Discord's cap on select menu options and default values +export const PAGE_SIZE = 25; + +export type Page = { + pageItems: string[]; + page: number; + pageCount: number; +}; + +// slices one select-menu page out of a list. Lists over PAGE_SIZE get an +// extra empty page after the last full one, so adding stays possible even +// when every existing page is full; stale page indexes clamp to the last page +export const paginate = (items: string[], rawPage: string | null): Page => { + const pageCount = + items.length <= PAGE_SIZE ? 1 : Math.floor(items.length / PAGE_SIZE) + 1; + const parsed = parseInt(rawPage ?? "0"); + const page = Math.min(Math.max(isNaN(parsed) ? 0 : parsed, 0), pageCount - 1); + return { + pageItems: items.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE), + page, + pageCount, + }; +}; + +// diffs a select submission against the visible page: entries missing from +// the submission are removals, submitted values not in the whole list are +// additions (checking the whole list keeps pages independent: re-selecting +// a value that lives on another page is not an add) +export const diffSelection = ({ + allItems, + pageItems, + submitted, +}: { + allItems: string[]; + pageItems: string[]; + submitted: string[]; +}): { added: string[]; removed: string[] } => { + const allSet = new Set(allItems); + const submittedSet = new Set(submitted); + return { + added: submitted.filter((value) => !allSet.has(value)), + removed: pageItems.filter((value) => !submittedSet.has(value)), + }; +}; diff --git a/src/routes/types.ts b/src/routes/types.ts new file mode 100644 index 0000000..c481800 --- /dev/null +++ b/src/routes/types.ts @@ -0,0 +1,29 @@ +import { + EmbedRouter, + Method, + RouteHandler, + RouteHandlers, +} from "discord-embed-router"; + +import { CommandName } from "@commands/commandNames"; + +export type Globals = { + commandIds: Map; +}; + +// sessions and locals are deliberately unused: panel state lives entirely in +// paths and query params, and route handlers import their db access directly +export type RingRouter = EmbedRouter; + +type Session = + RingRouter extends EmbedRouter ? S : never; +type Locals = + RingRouter extends EmbedRouter ? L : never; + +export type Handler = RouteHandler< + M, + Globals, + Session, + Locals +>; +export type Handlers = RouteHandlers; From e133fefd9fd17f106b59362e7deb76abc4e62fbe Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:12:48 -0400 Subject: [PATCH 03/82] add filter panel routes with shared members diff editor Co-Authored-By: Claude Fable 5 --- src/routes/filter.test.ts | 98 ++++++++++++++ src/routes/filter.ts | 254 +++++++++++++++++++++++++++++++++++ src/routes/lib/components.ts | 56 ++++++++ src/routes/lib/mentions.ts | 13 ++ src/routes/lib/scope.ts | 24 ++++ src/routes/types.ts | 5 + 6 files changed, 450 insertions(+) create mode 100644 src/routes/filter.test.ts create mode 100644 src/routes/filter.ts create mode 100644 src/routes/lib/components.ts create mode 100644 src/routes/lib/mentions.ts create mode 100644 src/routes/lib/scope.ts diff --git a/src/routes/filter.test.ts b/src/routes/filter.test.ts new file mode 100644 index 0000000..f030020 --- /dev/null +++ b/src/routes/filter.test.ts @@ -0,0 +1,98 @@ +import { Interaction } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { + addFilterEntry, + getFilter, + removeFilterEntry, +} from "@db/filters"; +import { filterHandlers } from "@routes/filter"; + +vi.mock("@db/filters", async (importOriginal) => ({ + ...(await importOriginal()), + getFilter: vi.fn(), + addFilterEntry: vi.fn(), + removeFilterEntry: vi.fn(), + setFilterType: vi.fn(), + resetFilter: vi.fn(), +})); + +const interaction = { user: { id: "caller" } } as unknown as Interaction; + +type MembersPost = NonNullable; +const membersState = ( + query: string, + values?: string[], +): Parameters[2] => + ({ + params: { scope: "global" }, + path: "/filter/global/members", + queryParams: new URLSearchParams(query), + timestamp: 0, + globals: { commandIds: new Map() }, + values, + }) as unknown as Parameters[2]; + +const membersPost = (query: string, values?: string[]) => + filterHandlers.members.post!( + undefined as never, + interaction, + membersState(query, values), + ); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("blocking while the global filter is a whitelist explains and mutates nothing", async () => { + vi.mocked(getFilter).mockResolvedValue({ + isWhitelist: true, + entries: new Set(), + }); + + const result = await membersPost("intent=block&add=42"); + + expect(result.redirect).toBe("/filter/global"); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("whitelist"); + expect(addFilterEntry).not.toHaveBeenCalled(); + expect(removeFilterEntry).not.toHaveBeenCalled(); +}); + +test("a select submission applies adds and removes from the visible page together", async () => { + vi.mocked(getFilter).mockResolvedValue({ + isWhitelist: false, + entries: new Set(["1", "2"]), + }); + + const result = await membersPost("page=0", ["2", "9"]); + + expect(addFilterEntry).toHaveBeenCalledExactlyOnceWith("caller", null, "9"); + expect(removeFilterEntry).toHaveBeenCalledExactlyOnceWith( + "caller", + null, + "1", + ); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("success"); +}); + +test("blocking an already blocked user reports it without a duplicate write", async () => { + vi.mocked(getFilter).mockResolvedValue({ + isWhitelist: false, + entries: new Set(["42"]), + }); + + const result = await membersPost("intent=block&add=42"); + + expect(addFilterEntry).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("flash")).toContain("already"); +}); diff --git a/src/routes/filter.ts b/src/routes/filter.ts new file mode 100644 index 0000000..ff35e36 --- /dev/null +++ b/src/routes/filter.ts @@ -0,0 +1,254 @@ +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, + RouteUserSelectMenuBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, +} from "discord.js"; + +import { + addFilterEntry, + filterType, + getFilter, + removeFilterEntry, + resetFilter, + setFilterType, +} from "@db/filters"; +import { joinWithAnd, mentionUser } from "@main/ring"; +import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; +import { Handler, Handlers } from "@routes/types"; + +const COLOR = "#94ab62"; + +const panelPath = (scope: string) => `/filter/${scope}`; + +const panelGet: Handler<"GET"> = async (router, interaction, state) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const filter = await getFilter(interaction.user.id, channelId); + const type = filterType(filter); + const entries = [...(filter?.entries ?? [])].sort(); + const { pageItems, page, pageCount } = paginate( + entries, + state.queryParams.get("page"), + ); + + const memberList = + pageItems.length > 0 ? pageItems.map(mentionUser).join(" ") : "None"; + const description = withFlash( + state.queryParams, + `${scopeName(scope, "filter", { capitalize: true })} is a **${type}**.\n` + + (type === "whitelist" + ? "Only the people listed here can ring you, and you only ring them." + : "The people listed here can't ring you, and you won't ring them.") + + `\n\n**Members${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:** ${memberList}`, + ); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle(scope === "global" ? "Your global filter" : "Channel filter") + .setDescription(description), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder( + channelId + ? "Viewing a channel filter (clear for global)" + : "View a voice channel's filter", + ) + .setDefaultChannels(...(channelId ? [channelId] : [])) + .setPattern("/filter{/:channelId}"), + ) + .toJSON(), + new ActionRowBuilder() + .addComponents( + new RouteUserSelectMenuBuilder(router) + .setMinValues(0) + .setMaxValues(PAGE_SIZE) + .setPlaceholder("Edit members: select to add, deselect to remove") + .setDefaultUsers(...pageItems) + .setPattern(`${panelPath(scope)}/members`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON(), + row( + new RouteButtonBuilder(router) + .setLabel( + type === "blacklist" ? "Switch to Whitelist" : "Switch to Blacklist", + ) + .setStyle(ButtonStyle.Primary) + .setTo(`${panelPath(scope)}/type`, { + method: "POST", + queryParams: { + to: type === "blacklist" ? "whitelist" : "blacklist", + }, + }), + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Danger) + .setTo(`${panelPath(scope)}/reset`, { method: "POST" }), + homeButton(router), + ), + ...paginationRows(router, panelPath(scope), { page, pageCount }), + ], + }; +}; + +// one members editor serves the panel select and the /block, /unblock, +// /whitelist and /unwhitelist adapters; `intent` keys the guard that stops +// blocklist-style edits on a whitelist (and vice versa) so the rule exists +// exactly once +const membersPost: Handler<"POST"> = async (router, interaction, state) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const userId = interaction.user.id; + const query = state.queryParams; + const intent = query.get("intent") ?? "edit"; + const panel = panelPath(scope); + + const filter = await getFilter(userId, channelId); + const type = filterType(filter); + + if (intent === "block" && type === "whitelist") + return flashRedirect( + panel, + "Your global filter is a whitelist. Switch it to a blacklist below, or use /whitelist and /unwhitelist instead.", + "warn", + ); + if (intent === "whitelist" && type === "blacklist") + return flashRedirect( + panel, + "Your global filter is a blacklist. Switch it to a whitelist below, or use /block and /unblock instead.", + "warn", + ); + + const entries = [...(filter?.entries ?? [])].sort(); + let addsRequested: string[]; + let removesRequested: string[]; + if (state.values) { + const { pageItems } = paginate(entries, query.get("page")); + ({ added: addsRequested, removed: removesRequested } = diffSelection({ + allItems: entries, + pageItems, + submitted: state.values, + })); + } else { + addsRequested = query.getAll("add"); + removesRequested = query.getAll("remove"); + } + + const entrySet = new Set(entries); + const toAdd = addsRequested.filter((id) => !entrySet.has(id)); + const toRemove = removesRequested.filter((id) => entrySet.has(id)); + await Promise.all([ + ...toAdd.map((id) => addFilterEntry(userId, channelId, id)), + ...toRemove.map((id) => removeFilterEntry(userId, channelId, id)), + ]); + + const changed = toAdd.length > 0 || toRemove.length > 0; + const target = mentionUser(addsRequested[0] ?? removesRequested[0] ?? ""); + let flash: string; + if (intent === "block") { + flash = + addsRequested.length > 0 + ? toAdd.length > 0 + ? `Blocked ${target}` + : `${target} is already blocked` + : toRemove.length > 0 + ? `Unblocked ${target}` + : `${target} isn't blocked`; + } else if (intent === "whitelist") { + flash = + addsRequested.length > 0 + ? toAdd.length > 0 + ? `Whitelisted ${target}` + : `${target} is already whitelisted` + : toRemove.length > 0 + ? `Removed ${target} from your whitelist` + : `${target} isn't on your whitelist`; + } else { + const parts = [ + ...(toAdd.length > 0 + ? [`Added ${joinWithAnd(toAdd.map(mentionUser))}`] + : []), + ...(toRemove.length > 0 + ? [`Removed ${joinWithAnd(toRemove.map(mentionUser))}`] + : []), + ]; + flash = + parts.length > 0 + ? `${parts.join(". ")} (${scopeName(scope, type)})` + : `No changes to ${scopeName(scope, type)}`; + } + + return flashRedirect(panel, flash, changed ? "success" : "warn", { + page: query.get("page") ?? "0", + }); +}; + +const typePost: Handler<"POST"> = async (router, interaction, state) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const panel = panelPath(scope); + const to = state.queryParams.get("to") === "whitelist" + ? "whitelist" + : "blacklist"; + + const filter = await getFilter(interaction.user.id, channelId); + if (filterType(filter) === to) + return flashRedirect( + panel, + `${scopeName(scope, "filter", { capitalize: true })} is already a ${to}`, + "warn", + ); + + await setFilterType(interaction.user.id, channelId, to === "whitelist"); + return flashRedirect( + panel, + `${scopeName(scope, "filter", { capitalize: true })} was reset and changed to a ${to}`, + "warn", + ); +}; + +const resetPost: Handler<"POST"> = async (router, interaction, state) => { + const scope = scopeOf(state.params); + const panel = panelPath(scope); + const wasNotDefault = await resetFilter( + interaction.user.id, + channelIdOf(scope), + ); + return wasNotDefault + ? flashRedirect( + panel, + `${scopeName(scope, "filter", { capitalize: true })} has been reset and is an empty blacklist`, + "success", + ) + : flashRedirect( + panel, + `${scopeName(scope, "filter", { capitalize: true })} is already the default (an empty blacklist)`, + "warn", + ); +}; + +export const filterHandlers = { + panel: { get: panelGet } satisfies Handlers, + members: { post: membersPost } satisfies Handlers, + type: { post: typePost } satisfies Handlers, + reset: { post: resetPost } satisfies Handlers, +}; diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts new file mode 100644 index 0000000..7393c57 --- /dev/null +++ b/src/routes/lib/components.ts @@ -0,0 +1,56 @@ +import { RouteButtonBuilder } from "discord-embed-router"; +import { + ActionRowBuilder, + APIActionRowComponent, + APIComponentInMessageActionRow, + ButtonStyle, +} from "discord.js"; + +import { Page } from "@routes/lib/paging"; +import { RingButton, RingRouter } from "@routes/types"; + +export const row = ( + ...components: RouteButtonBuilder[] +): APIActionRowComponent => + new ActionRowBuilder() + .addComponents(...components) + .toJSON(); + +export const homeButton = (router: RingRouter): RingButton => + new RouteButtonBuilder(router) + .setLabel("🏠 Home") + .setStyle(ButtonStyle.Secondary) + .setTo("/"); + +export const backButton = ( + router: RingRouter, + path: string, + label = "Back", +): RingButton => + new RouteButtonBuilder(router) + .setLabel(label) + .setStyle(ButtonStyle.Secondary) + .setTo(path); + +// the conditional pagination row: absent for single-page lists, so the +// result is spread into a components array +export const paginationRows = ( + router: RingRouter, + basePath: string, + { page, pageCount }: Pick, +): APIActionRowComponent[] => { + if (pageCount <= 1) return []; + const pageButton = (label: string, target: number, disabled: boolean) => + new RouteButtonBuilder(router) + .setLabel(label) + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled) + .setTo(basePath, { queryParams: { page: String(target) } }); + return [ + row( + pageButton("◀ Prev", page - 1, page === 0), + pageButton(`Page ${page + 1} of ${pageCount}`, page, true), + pageButton("Next ▶", page + 1, page === pageCount - 1), + ), + ]; +}; diff --git a/src/routes/lib/mentions.ts b/src/routes/lib/mentions.ts new file mode 100644 index 0000000..3169c81 --- /dev/null +++ b/src/routes/lib/mentions.ts @@ -0,0 +1,13 @@ +import { CommandName } from "@commands/commandNames"; +import { Globals } from "@routes/types"; + +// a clickable command mention when the deployed id is known, plain text +// otherwise (ids are fetched after the client is ready, so early +// interactions may miss them) +export const commandMention = ( + globals: Globals | undefined, + name: CommandName, +): string => { + const id = globals?.commandIds.get(name); + return id ? `` : `/${name}`; +}; diff --git a/src/routes/lib/scope.ts b/src/routes/lib/scope.ts new file mode 100644 index 0000000..c3de17a --- /dev/null +++ b/src/routes/lib/scope.ts @@ -0,0 +1,24 @@ +// scoped panels (filter, recipients) address their scope in the path: +// "global", or a voice channel id + +export const scopeOf = ( + params: Partial>, +): string => { + const scope = params.scope; + return typeof scope === "string" && scope.length > 0 ? scope : "global"; +}; + +export const channelIdOf = (scope: string): string | null => + scope === "global" ? null : scope; + +// "your global filter" / "your filter for <#id>" +export const scopeName = ( + scope: string, + noun: string, + { capitalize = false }: { capitalize?: boolean } = {}, +): string => { + const your = capitalize ? "Your" : "your"; + return scope === "global" + ? `${your} global ${noun}` + : `${your} ${noun} for <#${scope}>`; +}; diff --git a/src/routes/types.ts b/src/routes/types.ts index c481800..d7c442c 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -1,6 +1,7 @@ import { EmbedRouter, Method, + RouteButtonBuilder, RouteHandler, RouteHandlers, } from "discord-embed-router"; @@ -27,3 +28,7 @@ export type Handler = RouteHandler< Locals >; export type Handlers = RouteHandlers; + +// nameable alias for helper return types (declaration emit can't name the +// library's internal Unused symbol that Session/Locals infer to) +export type RingButton = RouteButtonBuilder; From 4295f985de4c2058d543b6c38ce6c9d2bc0c3725 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:14:54 -0400 Subject: [PATCH 04/82] add recipients and mode panel routes Co-Authored-By: Claude Fable 5 --- src/routes/mode.test.ts | 57 +++++++ src/routes/mode.ts | 84 +++++++++++ src/routes/recipients.test.ts | 80 ++++++++++ src/routes/recipients.ts | 270 ++++++++++++++++++++++++++++++++++ 4 files changed, 491 insertions(+) create mode 100644 src/routes/mode.test.ts create mode 100644 src/routes/mode.ts create mode 100644 src/routes/recipients.test.ts create mode 100644 src/routes/recipients.ts diff --git a/src/routes/mode.test.ts b/src/routes/mode.test.ts new file mode 100644 index 0000000..056131a --- /dev/null +++ b/src/routes/mode.test.ts @@ -0,0 +1,57 @@ +import { Interaction } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { getUserMode, setUserMode } from "@db/users"; +import { modeHandlers } from "@routes/mode"; + +vi.mock("@db/users", () => ({ + getUserMode: vi.fn(), + setUserMode: vi.fn(), +})); + +const interaction = { user: { id: "caller" } } as unknown as Interaction; + +type ModePost = NonNullable; +const state = (query: string) => + ({ + params: {}, + path: "/mode", + queryParams: new URLSearchParams(query), + timestamp: 0, + }) as unknown as Parameters[2]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("setting a mode persists it and confirms what it does", async () => { + vi.mocked(getUserMode).mockResolvedValue("normal"); + + const result = await modeHandlers.panel.post!( + undefined as never, + interaction, + state("set=stealth"), + ); + + expect(setUserMode).toHaveBeenCalledExactlyOnceWith("caller", "stealth"); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("success"); +}); + +test("an unknown mode value writes nothing", async () => { + vi.mocked(getUserMode).mockResolvedValue("normal"); + + const result = await modeHandlers.panel.post!( + undefined as never, + interaction, + state("set=loud"), + ); + + expect(setUserMode).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); +}); diff --git a/src/routes/mode.ts b/src/routes/mode.ts new file mode 100644 index 0000000..c992b6f --- /dev/null +++ b/src/routes/mode.ts @@ -0,0 +1,84 @@ +import { RouteButtonBuilder } from "discord-embed-router"; +import { ButtonStyle, EmbedBuilder } from "discord.js"; + +import { DiscordUserMode, getUserMode, setUserMode } from "@db/users"; +import { homeButton, row } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { Handler, Handlers } from "@routes/types"; + +const COLOR = "#F5853F"; +const PATH = "/mode"; + +const MODES: { mode: DiscordUserMode; label: string; effect: string }[] = [ + { + mode: "normal", + label: "Normal", + effect: "Joining a voice channel rings all applicable users", + }, + { + mode: "stealth", + label: "Stealth", + effect: "Joining a voice channel rings nobody", + }, + { + mode: "auto", + label: "Auto", + effect: "Stealth while you are invisible on Discord, normal otherwise", + }, +]; + +const isMode = (value: string | null): value is DiscordUserMode => + MODES.some(({ mode }) => mode === value); + +const panelGet: Handler<"GET"> = async (router, interaction, state) => { + const current = await getUserMode(interaction.user.id); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Your mode") + .setDescription( + withFlash( + state.queryParams, + `Modes decide what happens when you join a voice channel. Your current mode is **${current}**.`, + ), + ) + .addFields( + MODES.map(({ label, effect }) => ({ name: label, value: effect })), + ), + ], + components: [ + row( + ...MODES.map(({ mode, label }) => + new RouteButtonBuilder(router) + .setLabel(label) + .setStyle( + mode === current ? ButtonStyle.Primary : ButtonStyle.Secondary, + ) + .setDisabled(mode === current) + .setTo(PATH, { method: "POST", queryParams: { set: mode } }), + ), + homeButton(router), + ), + ], + }; +}; + +const panelPost: Handler<"POST"> = async (router, interaction, state) => { + const target = state.queryParams.get("set"); + if (!isMode(target)) + return flashRedirect(PATH, "Unknown mode, nothing changed", "warn"); + + const current = await getUserMode(interaction.user.id); + if (current === target) + return flashRedirect(PATH, `Your mode is already ${target}`, "warn"); + + await setUserMode(interaction.user.id, target); + const effect = MODES.find(({ mode }) => mode === target)?.effect ?? ""; + return flashRedirect(PATH, `Mode set to ${target}. ${effect}`, "success"); +}; + +export const modeHandlers = { + panel: { get: panelGet, post: panelPost } satisfies Handlers, +}; diff --git a/src/routes/recipients.test.ts b/src/routes/recipients.test.ts new file mode 100644 index 0000000..322f1e3 --- /dev/null +++ b/src/routes/recipients.test.ts @@ -0,0 +1,80 @@ +import { Interaction } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { setAutoRing, unsetAutoRing } from "@db/auto-ring"; +import { recipientsHandlers } from "@routes/recipients"; + +vi.mock("@db/auto-ring", () => ({ + getAutoRingSetting: vi.fn(), + setAutoRing: vi.fn(), + unsetAutoRing: vi.fn(), +})); +vi.mock("@db/default-ringees", () => ({ + getDefaultRingees: vi.fn(), + addDefaultRingee: vi.fn(), + removeDefaultRingee: vi.fn(), + clearDefaultRingees: vi.fn(), +})); + +const interaction = { user: { id: "caller" } } as unknown as Interaction; + +type AutoRingPost = NonNullable; +const autoRingState = (scope: string, query: string) => + ({ + params: { scope }, + path: `/recipients/${scope}/auto-ring`, + queryParams: new URLSearchParams(query), + timestamp: 0, + }) as unknown as Parameters[2]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("enabling auto-ring warns that joins ring default recipients even in stealth", async () => { + vi.mocked(setAutoRing).mockResolvedValue(true); + + const result = await recipientsHandlers.autoRing.post!( + undefined as never, + interaction, + autoRingState("123", "enable=1"), + ); + + expect(setAutoRing).toHaveBeenCalledExactlyOnceWith("caller", "123", true); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("stealth"); +}); + +test("toggling auto-ring to its current value reports no change", async () => { + vi.mocked(setAutoRing).mockResolvedValue(false); + + const result = await recipientsHandlers.autoRing.post!( + undefined as never, + interaction, + autoRingState("global", "enable=0"), + ); + + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("flash")).toContain("already"); +}); + +test("removing a missing auto-ring override reports there was none", async () => { + vi.mocked(unsetAutoRing).mockResolvedValue(false); + + const result = await recipientsHandlers.autoRingUnset.post!( + undefined as never, + interaction, + autoRingState("123", ""), + ); + + expect(unsetAutoRing).toHaveBeenCalledExactlyOnceWith("caller", "123"); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); +}); diff --git a/src/routes/recipients.ts b/src/routes/recipients.ts new file mode 100644 index 0000000..03b4d36 --- /dev/null +++ b/src/routes/recipients.ts @@ -0,0 +1,270 @@ +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, + RouteUserSelectMenuBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, +} from "discord.js"; + +import { getAutoRingSetting, setAutoRing, unsetAutoRing } from "@db/auto-ring"; +import { + addDefaultRingee, + clearDefaultRingees, + getDefaultRingees, + removeDefaultRingee, +} from "@db/default-ringees"; +import { joinWithAnd, mentionUser } from "@main/ring"; +import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { channelIdOf, scopeOf } from "@routes/lib/scope"; +import { Handler, Handlers } from "@routes/types"; + +const COLOR = "#747ac5"; + +const panelPath = (scope: string) => `/recipients/${scope}`; + +// "for <#id>" / "globally" +const scopeSuffix = (scope: string) => + scope === "global" ? "globally" : `for <#${scope}>`; + +// the channel override when in channel scope, the global setting, and the +// value that actually applies for the scope +const autoRingStatus = async (userId: string, channelId: string | null) => { + const [override, global] = await Promise.all([ + channelId === null + ? Promise.resolve(undefined) + : getAutoRingSetting(userId, channelId), + getAutoRingSetting(userId, null), + ]); + return { override, global: global ?? false, effective: override ?? global ?? false }; +}; + +const panelGet: Handler<"GET"> = async (router, interaction, state) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const userId = interaction.user.id; + const [ringees, autoRing] = await Promise.all([ + getDefaultRingees(userId, channelId), + autoRingStatus(userId, channelId), + ]); + const sorted = [...ringees].sort(); + const { pageItems, page, pageCount } = paginate( + sorted, + state.queryParams.get("page"), + ); + + const autoRingLine = + channelId === null + ? `Auto-ring: **${autoRing.effective ? "enabled" : "disabled"}**` + : autoRing.override !== undefined + ? `Auto-ring: **${autoRing.override ? "enabled" : "disabled"}** for <#${channelId}> (overrides your global setting: ${autoRing.global ? "enabled" : "disabled"})` + : `Auto-ring: **${autoRing.effective ? "enabled" : "disabled"}** (from your global setting)`; + + const memberList = + pageItems.length > 0 ? pageItems.map(mentionUser).join(" ") : "None"; + const description = withFlash( + state.queryParams, + `These people get rung when you use Ring defaults${channelId ? ` in <#${channelId}>` : ""}, or on every voice channel join if auto-ring is enabled.\n\n` + + `${autoRingLine}\n\n` + + `**Recipients${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:** ${memberList}`, + ); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle( + scope === "global" + ? "Your default ring recipients" + : "Channel ring recipients", + ) + .setDescription(description), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder( + channelId + ? "Viewing channel recipients (clear for global)" + : "View a voice channel's recipients", + ) + .setDefaultChannels(...(channelId ? [channelId] : [])) + .setPattern("/recipients{/:channelId}"), + ) + .toJSON(), + new ActionRowBuilder() + .addComponents( + new RouteUserSelectMenuBuilder(router) + .setMinValues(0) + .setMaxValues(PAGE_SIZE) + .setPlaceholder( + "Edit recipients: select to add, deselect to remove", + ) + .setDefaultUsers(...pageItems) + .setPattern(`${panelPath(scope)}/members`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON(), + row( + new RouteButtonBuilder(router) + .setLabel("Clear all") + .setStyle(ButtonStyle.Danger) + .setTo(`${panelPath(scope)}/clear`, { method: "POST" }), + new RouteButtonBuilder(router) + .setLabel( + autoRing.effective ? "Disable auto-ring" : "Enable auto-ring", + ) + .setStyle( + autoRing.effective ? ButtonStyle.Secondary : ButtonStyle.Success, + ) + .setTo(`${panelPath(scope)}/auto-ring`, { + method: "POST", + queryParams: { enable: autoRing.effective ? "0" : "1" }, + }), + ...(channelId !== null && autoRing.override !== undefined + ? [ + new RouteButtonBuilder(router) + .setLabel("Remove override") + .setStyle(ButtonStyle.Secondary) + .setTo(`${panelPath(scope)}/auto-ring/unset`, { + method: "POST", + }), + ] + : []), + homeButton(router), + ), + ...paginationRows(router, panelPath(scope), { page, pageCount }), + ], + }; +}; + +const membersPost: Handler<"POST"> = async (router, interaction, state) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const userId = interaction.user.id; + const panel = panelPath(scope); + + const ringees = [...(await getDefaultRingees(userId, channelId))].sort(); + const { pageItems } = paginate(ringees, state.queryParams.get("page")); + const { added, removed } = diffSelection({ + allItems: ringees, + pageItems, + submitted: state.values ?? pageItems, + }); + + await Promise.all([ + ...added.map((id) => addDefaultRingee(userId, channelId, id)), + ...removed.map((id) => removeDefaultRingee(userId, channelId, id)), + ]); + + const parts = [ + ...(added.length > 0 + ? [`Added ${joinWithAnd(added.map(mentionUser))}`] + : []), + ...(removed.length > 0 + ? [`Removed ${joinWithAnd(removed.map(mentionUser))}`] + : []), + ]; + const changed = parts.length > 0; + return flashRedirect( + panel, + changed + ? `${parts.join(". ")} (default ring recipients ${scopeSuffix(scope)})` + : `No changes to your default ring recipients ${scopeSuffix(scope)}`, + changed ? "success" : "warn", + { page: state.queryParams.get("page") ?? "0" }, + ); +}; + +const clearPost: Handler<"POST"> = async (router, interaction, state) => { + const scope = scopeOf(state.params); + const cleared = await clearDefaultRingees( + interaction.user.id, + channelIdOf(scope), + ); + return cleared + ? flashRedirect( + panelPath(scope), + `Cleared your default ring recipients ${scopeSuffix(scope)}`, + "success", + ) + : flashRedirect( + panelPath(scope), + `You already have no default ring recipients ${scopeSuffix(scope)}`, + "warn", + ); +}; + +const autoRingPost: Handler<"POST"> = async (router, interaction, state) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const enable = state.queryParams.get("enable") === "1"; + const panel = panelPath(scope); + + const changed = await setAutoRing(interaction.user.id, channelId, enable); + if (!changed) + return flashRedirect( + panel, + `Auto-ring is already ${enable ? "enabled" : "disabled"} ${scopeSuffix(scope)}`, + "warn", + ); + return enable + ? flashRedirect( + panel, + `Auto-ring is now enabled ${scopeSuffix(scope)}. WARNING: joining ${channelId ? `<#${channelId}>` : "a voice channel"} now rings all of your default ring recipients, even in stealth mode`, + "warn", + ) + : flashRedirect( + panel, + `Auto-ring is now disabled ${scopeSuffix(scope)}`, + "success", + ); +}; + +const autoRingUnsetPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const panel = panelPath(scope); + if (channelId === null) + return flashRedirect( + panel, + "Only channel scopes have an auto-ring override to remove", + "warn", + ); + + const existed = await unsetAutoRing(interaction.user.id, channelId); + return existed + ? flashRedirect( + panel, + `Removed the auto-ring override for <#${channelId}>; your global setting applies again`, + "success", + ) + : flashRedirect( + panel, + `You have no auto-ring override for <#${channelId}>`, + "warn", + ); +}; + +export const recipientsHandlers = { + panel: { get: panelGet } satisfies Handlers, + members: { post: membersPost } satisfies Handlers, + clear: { post: clearPost } satisfies Handlers, + autoRing: { post: autoRingPost } satisfies Handlers, + autoRingUnset: { post: autoRingUnsetPost } satisfies Handlers, +}; From f71842d5a696216d73dbed1eb8538dde679a0050 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:18:13 -0400 Subject: [PATCH 05/82] add signups panel routes with role signup pages and db queries Co-Authored-By: Claude Fable 5 --- src/main/db/voice-chats.ts | 28 +++ src/routes/signups.test.ts | 86 +++++++ src/routes/signups.ts | 456 +++++++++++++++++++++++++++++++++++++ 3 files changed, 570 insertions(+) create mode 100644 src/routes/signups.test.ts create mode 100644 src/routes/signups.ts diff --git a/src/main/db/voice-chats.ts b/src/main/db/voice-chats.ts index 28f70fd..9b3cabd 100644 --- a/src/main/db/voice-chats.ts +++ b/src/main/db/voice-chats.ts @@ -27,6 +27,34 @@ export const getVoiceChatSignups = async ( }; }; +// every channel the user is signed up for, across all guilds; callers +// scope the result to a guild by intersecting with its channels +export const getUserVoiceChatSignups = async ( + userId: string, +): Promise => { + const rows = rowsOf( + await db.from("voice_chat_users").select("channel_id").eq("user_id", userId), + ); + return rows.map((row) => row.channel_id); +}; + +// role signups for a set of channels (typically one guild's voice channels) +export const getVoiceChatRoleSignups = async ( + channelIds: string[], +): Promise<{ channelId: string; roleId: string }[]> => { + if (channelIds.length === 0) return []; + const rows = rowsOf( + await db + .from("voice_chat_roles") + .select("channel_id, role_id") + .in("channel_id", channelIds), + ); + return rows.map((row) => ({ + channelId: row.channel_id, + roleId: row.role_id, + })); +}; + // returns whether the user was newly signed up (false if already signed up) export const addVoiceChatUser = async ( channelId: string, diff --git a/src/routes/signups.test.ts b/src/routes/signups.test.ts new file mode 100644 index 0000000..0687d24 --- /dev/null +++ b/src/routes/signups.test.ts @@ -0,0 +1,86 @@ +import { Interaction, PermissionsBitField } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { addVoiceChatRole } from "@db/voice-chats"; +import { signupsHandlers } from "@routes/signups"; + +vi.mock("@db/voice-chats", () => ({ + getUserVoiceChatSignups: vi.fn(), + getVoiceChatRoleSignups: vi.fn(), + addVoiceChatUser: vi.fn(), + removeVoiceChatUser: vi.fn(), + addVoiceChatRole: vi.fn(), + removeVoiceChatRole: vi.fn(), +})); + +const voiceChannel = { id: "vc1", isVoiceBased: () => true }; +const makeInteraction = (admin: boolean) => + ({ + user: { id: "caller" }, + memberPermissions: new PermissionsBitField( + admin ? PermissionsBitField.Flags.ManageRoles : 0n, + ), + guild: { channels: { cache: new Map([[voiceChannel.id, voiceChannel]]) } }, + }) as unknown as Interaction; + +type RoleCommitPost = NonNullable; +const commitState = (query: string) => + ({ + params: { roleId: "role1" }, + path: "/signups/roles/role1", + queryParams: new URLSearchParams(query), + timestamp: 0, + }) as unknown as Parameters[2]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("committing a role signup that already exists rejects with a flash", async () => { + vi.mocked(addVoiceChatRole).mockResolvedValue(false); + + const result = await signupsHandlers.roleCommit.post!( + undefined as never, + makeInteraction(true), + commitState("channel=vc1"), + ); + + expect(result.redirect).toBe("/signups/roles"); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("already"); +}); + +test("committing a role signup succeeds and reports the mapping", async () => { + vi.mocked(addVoiceChatRole).mockResolvedValue(true); + + const result = await signupsHandlers.roleCommit.post!( + undefined as never, + makeInteraction(true), + commitState("channel=vc1"), + ); + + expect(addVoiceChatRole).toHaveBeenCalledExactlyOnceWith("vc1", "role1"); + expect(result.redirect).toBe("/signups/roles"); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("success"); +}); + +test("a role signup commit without Manage Roles mutates nothing", async () => { + const result = await signupsHandlers.roleCommit.post!( + undefined as never, + makeInteraction(false), + commitState("channel=vc1"), + ); + + expect(addVoiceChatRole).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("Manage Roles"); +}); diff --git a/src/routes/signups.ts b/src/routes/signups.ts new file mode 100644 index 0000000..3972b1d --- /dev/null +++ b/src/routes/signups.ts @@ -0,0 +1,456 @@ +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, + RouteRoleSelectMenuBuilder, + RouteStringSelectMenuBuilder, + RouteStringSelectMenuOptionBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, + Guild, + Interaction, + PermissionsBitField, +} from "discord.js"; + +import { + addVoiceChatRole, + addVoiceChatUser, + getUserVoiceChatSignups, + getVoiceChatRoleSignups, + removeVoiceChatRole, + removeVoiceChatUser, +} from "@db/voice-chats"; +import { joinWithAnd, mentionRole } from "@main/ring"; +import { + backButton, + homeButton, + paginationRows, + row, +} from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { Handler, Handlers } from "@routes/types"; + +const COLOR = "#62a8ab"; +const PANEL = "/signups"; +const ROLES = "/signups/roles"; + +const mentionChannel = (channelId: string) => `<#${channelId}>`; + +const canManageRoleSignups = (interaction: Interaction): boolean => + interaction.memberPermissions?.has( + PermissionsBitField.Flags.ManageRoles, + ) === true || + interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) === true; + +const guildVoiceChannelIds = (guild: Guild): string[] => + [...guild.channels.cache.values()] + .filter((channel) => channel.isVoiceBased()) + .map((channel) => channel.id); + +// panels are ephemeral and per-guild; anything reached outside a guild +// (e.g. a DM) has nothing to show +const guildOnlyRender = { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Signups") + .setDescription("⚠️ Signups only work inside a Discord server"), + ], + components: [], +}; + +// the user's signups, restricted to this guild's voice channels and sorted +// by channel name for stable paging +const guildSignups = async (userId: string, guild: Guild): Promise => { + const channelIds = new Set(guildVoiceChannelIds(guild)); + return (await getUserVoiceChatSignups(userId)) + .filter((channelId) => channelIds.has(channelId)) + .sort((a, b) => + (guild.channels.cache.get(a)?.name ?? a).localeCompare( + guild.channels.cache.get(b)?.name ?? b, + ), + ); +}; + +const panelGet: Handler<"GET"> = async (router, interaction, state) => { + const guild = interaction.guild; + if (!guild) return { ...guildOnlyRender, components: [row(homeButton(router))] }; + + const signups = await guildSignups(interaction.user.id, guild); + const { pageItems, page, pageCount } = paginate( + signups, + state.queryParams.get("page"), + ); + + const channelList = + pageItems.length > 0 ? pageItems.map(mentionChannel).join(" ") : "None"; + const description = withFlash( + state.queryParams, + "You get rung when someone starts a call in one of these channels.\n\n" + + `**Your signups in this server${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:** ${channelList}`, + ); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Your signups") + .setDescription(description), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(PAGE_SIZE) + .setPlaceholder("Edit signups: select to add, deselect to remove") + .setDefaultChannels(...pageItems) + .setPattern(`${PANEL}/members`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON(), + row( + new RouteButtonBuilder(router) + .setLabel("Role signups") + .setStyle(ButtonStyle.Secondary) + .setTo(ROLES), + homeButton(router), + ), + ...paginationRows(router, PANEL, { page, pageCount }), + ], + }; +}; + +// serves the panel's diff select and the /signup, /unsignup and /quit +// adapters (which pass add/remove channel ids as query params) +const membersPost: Handler<"POST"> = async (router, interaction, state) => { + const guild = interaction.guild; + if (!guild) + return flashRedirect( + PANEL, + "Signups only work inside a Discord server", + "warn", + ); + + const userId = interaction.user.id; + const query = state.queryParams; + let addsRequested: string[]; + let removesRequested: string[]; + if (state.values) { + const signups = await guildSignups(userId, guild); + const { pageItems } = paginate(signups, query.get("page")); + ({ added: addsRequested, removed: removesRequested } = diffSelection({ + allItems: signups, + pageItems, + submitted: state.values, + })); + } else { + addsRequested = query.getAll("add"); + removesRequested = query.getAll("remove"); + } + + const added: string[] = []; + const alreadySignedUp: string[] = []; + for (const channelId of addsRequested) { + (await addVoiceChatUser(channelId, userId)) + ? added.push(channelId) + : alreadySignedUp.push(channelId); + } + const removed: string[] = []; + const notSignedUp: string[] = []; + for (const channelId of removesRequested) { + (await removeVoiceChatUser(channelId, userId)) + ? removed.push(channelId) + : notSignedUp.push(channelId); + } + + const parts = [ + ...(added.length > 0 + ? [`Signed up for ${joinWithAnd(added.map(mentionChannel))}`] + : []), + ...(alreadySignedUp.length > 0 + ? [ + `You are already signed up for ${joinWithAnd(alreadySignedUp.map(mentionChannel))}`, + ] + : []), + ...(removed.length > 0 + ? [ + `You will no longer be rung for ${joinWithAnd(removed.map(mentionChannel))}`, + ] + : []), + ...(notSignedUp.length > 0 + ? [`You aren't signed up for ${joinWithAnd(notSignedUp.map(mentionChannel))}`] + : []), + ]; + const changed = added.length > 0 || removed.length > 0; + return flashRedirect( + PANEL, + parts.length > 0 ? parts.join(". ") : "No changes to your signups", + changed ? "success" : "warn", + { page: query.get("page") ?? "0" }, + ); +}; + +const noPermissionRender = (router: Parameters>[0]) => ({ + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Role signups") + .setDescription( + "⚠️ You need the Manage Roles permission to manage role signups", + ), + ], + components: [row(backButton(router, PANEL), homeButton(router))], +}); + +// stable ordering for paging: by role name, then channel name +const sortedRoleSignups = async (guild: Guild) => { + const nameOf = (roleId: string, channelId: string) => + `${guild.roles.cache.get(roleId)?.name ?? roleId} ${guild.channels.cache.get(channelId)?.name ?? channelId}`; + return (await getVoiceChatRoleSignups(guildVoiceChannelIds(guild))).sort( + (a, b) => + nameOf(a.roleId, a.channelId).localeCompare(nameOf(b.roleId, b.channelId)), + ); +}; + +const rolesGet: Handler<"GET"> = async (router, interaction, state) => { + const guild = interaction.guild; + if (!guild) return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!canManageRoleSignups(interaction)) return noPermissionRender(router); + + const mappings = await sortedRoleSignups(guild); + const pageStrings = mappings.map( + ({ roleId, channelId }) => `${roleId}:${channelId}`, + ); + const { pageItems, page, pageCount } = paginate( + pageStrings, + state.queryParams.get("page"), + ); + + const mappingLines = + pageItems.length > 0 + ? pageItems + .map((pair) => { + const [roleId = "", channelId = ""] = pair.split(":"); + return `${mentionRole(roleId)} → ${mentionChannel(channelId)}`; + }) + .join("\n") + : "None yet"; + const description = withFlash( + state.queryParams, + "When someone joins a channel, its signed-up roles get pinged.\n\n" + + `**Role signups${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:**\n${mappingLines}`, + ); + + const removeOptions = pageItems.map((pair) => { + const [roleId = "", channelId = ""] = pair.split(":"); + return new RouteStringSelectMenuOptionBuilder(router) + .setLabel( + `@${guild.roles.cache.get(roleId)?.name ?? roleId} → #${guild.channels.cache.get(channelId)?.name ?? channelId}`, + ) + .setTo(`${ROLES}/remove`, { queryParams: { pair } }); + }); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Role signups") + .setDescription(description), + ], + components: [ + ...(removeOptions.length > 0 + ? [ + new ActionRowBuilder() + .addComponents( + new RouteStringSelectMenuBuilder(router) + .setTos(...removeOptions) + .setPlaceholder("Remove role signups") + .setMinValues(1) + .setMaxValues(removeOptions.length) + .setPattern(`${ROLES}/remove`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON(), + ] + : []), + new ActionRowBuilder() + .addComponents( + new RouteRoleSelectMenuBuilder(router) + .setMinValues(1) + .setMaxValues(1) + .setPlaceholder("Add a role signup: pick a role") + .setPattern(`${ROLES}/:roleId`), + ) + .toJSON(), + row(backButton(router, PANEL), homeButton(router)), + ...paginationRows(router, ROLES, { page, pageCount }), + ], + }; +}; + +// a remove-select value decodes to "?pair=:" +const pairOf = (value: string): { roleId: string; channelId: string } | null => { + const [, query = ""] = value.split("?"); + const [roleId, channelId] = + new URLSearchParams(query).get("pair")?.split(":") ?? []; + return roleId && channelId ? { roleId, channelId } : null; +}; + +// serves the panel's remove select and the /unsignuprole adapter (role and +// optional channel as query params; no channel removes the role everywhere) +const rolesRemovePost: Handler<"POST"> = async (router, interaction, state) => { + const guild = interaction.guild; + if (!guild) + return flashRedirect( + ROLES, + "Signups only work inside a Discord server", + "warn", + ); + if (!canManageRoleSignups(interaction)) + return flashRedirect( + ROLES, + "You need the Manage Roles permission to manage role signups", + "warn", + ); + + const query = state.queryParams; + let pairs: { roleId: string; channelId: string }[]; + if (state.values) { + pairs = state.values + .map(pairOf) + .filter((pair): pair is NonNullable => pair !== null); + } else { + const roleId = query.get("role") ?? ""; + const channelId = query.get("channel"); + pairs = channelId + ? [{ roleId, channelId }] + : (await sortedRoleSignups(guild)).filter( + (mapping) => mapping.roleId === roleId, + ); + if (pairs.length === 0) + return flashRedirect( + ROLES, + `${mentionRole(roleId)} isn't signed up for any voice channel`, + "warn", + ); + } + + const removed: typeof pairs = []; + const missing: typeof pairs = []; + for (const pair of pairs) { + (await removeVoiceChatRole(pair.channelId, pair.roleId)) + ? removed.push(pair) + : missing.push(pair); + } + + const describe = (list: typeof pairs) => + joinWithAnd( + list.map( + ({ roleId, channelId }) => + `${mentionRole(roleId)} from ${mentionChannel(channelId)}`, + ), + ); + const parts = [ + ...(removed.length > 0 ? [`Removed ${describe(removed)}`] : []), + ...(missing.length > 0 + ? [`Already removed: ${describe(missing)}`] + : []), + ]; + return flashRedirect( + ROLES, + parts.length > 0 ? parts.join(". ") : "No role signups to remove", + removed.length > 0 ? "success" : "warn", + { page: query.get("page") ?? "0" }, + ); +}; + +const rolePickGet: Handler<"GET"> = async (router, interaction, state) => { + const guild = interaction.guild; + if (!guild) return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!canManageRoleSignups(interaction)) return noPermissionRender(router); + + const roleId = typeof state.params.roleId === "string" ? state.params.roleId : ""; + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Add a role signup") + .setDescription( + withFlash( + state.queryParams, + `Signing up ${mentionRole(roleId)}: pick the voice channel it should be pinged for.`, + ), + ), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(1) + .setMaxValues(1) + .setPlaceholder(`Pick a channel for the role`) + .setPattern(`${ROLES}/${roleId}`, { + method: "POST", + queryParams: { channel: ":channelId" }, + }), + ) + .toJSON(), + row(backButton(router, ROLES, "Cancel"), homeButton(router)), + ], + }; +}; + +const roleCommitPost: Handler<"POST"> = async (router, interaction, state) => { + if (!canManageRoleSignups(interaction)) + return flashRedirect( + ROLES, + "You need the Manage Roles permission to manage role signups", + "warn", + ); + + const roleId = typeof state.params.roleId === "string" ? state.params.roleId : ""; + const channelId = state.queryParams.get("channel") ?? ""; + const channel = interaction.guild?.channels.cache.get(channelId); + if (!channel?.isVoiceBased()) + return flashRedirect( + `${ROLES}/${roleId}`, + "Pick a voice channel to finish the role signup", + "warn", + ); + + const added = await addVoiceChatRole(channelId, roleId); + return added + ? flashRedirect( + ROLES, + `Signed up ${mentionRole(roleId)} for ${mentionChannel(channelId)}`, + "success", + ) + : flashRedirect( + ROLES, + `${mentionRole(roleId)} is already signed up for ${mentionChannel(channelId)}`, + "warn", + ); +}; + +export const signupsHandlers = { + panel: { get: panelGet } satisfies Handlers, + members: { post: membersPost } satisfies Handlers, + roles: { get: rolesGet } satisfies Handlers, + rolesRemove: { post: rolesRemovePost } satisfies Handlers, + rolePick: { get: rolePickGet } satisfies Handlers, + roleCommit: { post: roleCommitPost } satisfies Handlers, +}; From 661c5a63245d33855776e020f829531d46886722 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:48:25 -0400 Subject: [PATCH 06/82] add ring, delete-data, and home panel routes with route registry Co-Authored-By: Claude Fable 5 --- src/routes/deleteData.test.ts | 58 +++++++++++ src/routes/deleteData.ts | 93 ++++++++++++++++++ src/routes/home.ts | 125 ++++++++++++++++++++++++ src/routes/index.ts | 47 +++++++++ src/routes/lib/components.ts | 7 +- src/routes/ring.test.ts | 76 +++++++++++++++ src/routes/ring.ts | 175 ++++++++++++++++++++++++++++++++++ src/routes/signups.test.ts | 8 +- src/routes/signups.ts | 3 +- 9 files changed, 582 insertions(+), 10 deletions(-) create mode 100644 src/routes/deleteData.test.ts create mode 100644 src/routes/deleteData.ts create mode 100644 src/routes/home.ts create mode 100644 src/routes/index.ts create mode 100644 src/routes/ring.test.ts create mode 100644 src/routes/ring.ts diff --git a/src/routes/deleteData.test.ts b/src/routes/deleteData.test.ts new file mode 100644 index 0000000..525cecb --- /dev/null +++ b/src/routes/deleteData.test.ts @@ -0,0 +1,58 @@ +import { Interaction } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { deleteAllUserData } from "@db/users"; +import { deleteDataHandlers } from "@routes/deleteData"; + +vi.mock("@db/users", () => ({ + deleteAllUserData: vi.fn(), +})); + +const interaction = { user: { id: "caller" } } as unknown as Interaction; + +type DeletePost = NonNullable; +const state = (confirmation: string) => + ({ + params: {}, + path: "/delete-data", + queryParams: new URLSearchParams(), + timestamp: 0, + fields: { getTextInputValue: () => confirmation }, + }) as unknown as Parameters[2]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("typing DELETE exactly deletes all user data", async () => { + vi.mocked(deleteAllUserData).mockResolvedValue(true); + + const result = await deleteDataHandlers.panel.post!( + undefined as never, + interaction, + state("DELETE"), + ); + + expect(deleteAllUserData).toHaveBeenCalledExactlyOnceWith("caller"); + expect(result.redirect).toBe("/delete-data"); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("success"); + expect(flashParams.get("done")).toBe("1"); +}); + +test("a mismatched confirmation deletes nothing", async () => { + const result = await deleteDataHandlers.panel.post!( + undefined as never, + interaction, + state("delete"), + ); + + expect(deleteAllUserData).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("did not match"); +}); diff --git a/src/routes/deleteData.ts b/src/routes/deleteData.ts new file mode 100644 index 0000000..bda8605 --- /dev/null +++ b/src/routes/deleteData.ts @@ -0,0 +1,93 @@ +import { + RouteButtonBuilder, + RouteModalBuilder, +} from "discord-embed-router"; +import { + ButtonStyle, + EmbedBuilder, + LabelBuilder, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; + +import { deleteAllUserData } from "@db/users"; +import { row } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { Handler, Handlers } from "@routes/types"; + +const COLOR = "#ca2b2b"; +const PANEL = "/delete-data"; +const CONFIRMATION = "DELETE"; + +const panelGet: Handler<"GET"> = (router, interaction, state) => { + // set by the deletion redirect: the panel has done its job, so its + // buttons render disabled + const done = state.queryParams.get("done") === "1"; + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Delete all data") + .setDescription( + withFlash( + state.queryParams, + "Are you sure you want to delete all your data? This will remove all your filters, signups, and other account settings. This is irreversible.", + ), + ), + ], + components: [ + row( + new RouteButtonBuilder(router) + .setLabel("Delete") + .setStyle(ButtonStyle.Danger) + .setDisabled(done) + .setTo(`${PANEL}/confirm`, { method: "MODAL" }), + new RouteButtonBuilder(router) + .setLabel("Cancel") + .setStyle(ButtonStyle.Secondary) + .setDisabled(done) + .setTo("/"), + ), + ], + }; +}; + +const confirmModal: Handler<"MODAL"> = (router) => + new RouteModalBuilder(router) + .setTo(PANEL, { method: "POST" }) + .setTitle("Delete all data") + .addLabelComponents( + new LabelBuilder() + .setLabel(`Type ${CONFIRMATION} to confirm`) + .setTextInputComponent( + new TextInputBuilder() + .setCustomId("confirm") + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setPlaceholder(CONFIRMATION), + ), + ); + +const panelPost: Handler<"POST"> = async (router, interaction, state) => { + const confirmation = state.fields?.getTextInputValue("confirm") ?? ""; + if (confirmation !== CONFIRMATION) + return flashRedirect( + PANEL, + "Confirmation text did not match, nothing was deleted", + "warn", + ); + + const hadData = await deleteAllUserData(interaction.user.id); + return flashRedirect( + PANEL, + hadData ? "All your data has been deleted" : "You had no stored data to delete", + hadData ? "success" : "warn", + { done: "1" }, + ); +}; + +export const deleteDataHandlers = { + panel: { get: panelGet, post: panelPost } satisfies Handlers, + confirm: { modal: confirmModal } satisfies Handlers, +}; diff --git a/src/routes/home.ts b/src/routes/home.ts new file mode 100644 index 0000000..f7e9a46 --- /dev/null +++ b/src/routes/home.ts @@ -0,0 +1,125 @@ +import { RouteButtonBuilder } from "discord-embed-router"; +import { ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; + +import { CommandName } from "@commands/commandNames"; +import { backButton, homeButton, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; +import { Handler, Handlers } from "@routes/types"; + +const COLOR = "#b574c5"; +const GITHUB_URL = "https://github.com/altrup/RingVC"; +const SUPPORT_URL = "https://discord.gg/bxBePEnndq"; + +const panelGet: Handler<"GET"> = (router, interaction, state) => { + const mention = (name: CommandName) => commandMention(state.globals, name); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("RingVC") + .setDescription( + withFlash( + state.queryParams, + "RingVC replicates group-chat voice calls in Discord servers: sign up for a voice channel and get pinged when someone starts a call there.", + ), + ) + .addFields( + { + name: "Quick commands", + value: `${mention("signup")} to get rung for a voice channel, ${mention("ring")} to ring people into yours, and ${mention("block")} to stop someone from ringing you.`, + }, + { + name: "Panels", + value: + "Everything the commands do can also be done from the panels below.", + }, + ), + ], + components: [ + row( + new RouteButtonBuilder(router) + .setLabel("Signups") + .setStyle(ButtonStyle.Secondary) + .setTo("/signups"), + new RouteButtonBuilder(router) + .setLabel("Filter") + .setStyle(ButtonStyle.Secondary) + .setTo("/filter/global"), + new RouteButtonBuilder(router) + .setLabel("Ring recipients") + .setStyle(ButtonStyle.Secondary) + .setTo("/recipients/global"), + new RouteButtonBuilder(router) + .setLabel("Mode") + .setStyle(ButtonStyle.Secondary) + .setTo("/mode"), + new RouteButtonBuilder(router) + .setLabel("Delete data") + .setStyle(ButtonStyle.Secondary) + .setTo("/delete-data"), + ), + row( + new RouteButtonBuilder(router) + .setLabel("Commands") + .setStyle(ButtonStyle.Secondary) + .setTo("/commands"), + new ButtonBuilder() + .setLabel("Github") + .setStyle(ButtonStyle.Link) + .setURL(GITHUB_URL), + new ButtonBuilder() + .setLabel("Support Server") + .setStyle(ButtonStyle.Link) + .setURL(SUPPORT_URL), + ), + ], + }; +}; + +const commandsGet: Handler<"GET"> = (router, interaction, state) => { + const mention = (name: CommandName) => commandMention(state.globals, name); + const quickActions: [CommandName, string][] = [ + ["signup", "sign up for a voice channel (bare, in a VC's text chat)"], + ["unsignup", "remove a signup"], + ["quit", "same as /unsignup"], + ["signuprole", "sign up a role for a channel (Manage Roles)"], + ["unsignuprole", "remove a role signup (Manage Roles)"], + ["block", "block someone from ringing you"], + ["unblock", "unblock them"], + ["whitelist", "allow someone through your whitelist"], + ["unwhitelist", "remove them from your whitelist"], + ["ring", "ring someone into your voice channel (with a user)"], + ]; + const panels: [CommandName, string][] = [ + ["help", "the home panel"], + ["signup", "your signups panel (bare, outside a voice channel)"], + ["filter", "your filter panel"], + ["default_ring_recipients", "ring recipients and auto-ring panel"], + ["mode", "mode panel"], + ["ring", "ring panel (bare)"], + ["delete_data", "data deletion panel"], + ]; + const list = (entries: [CommandName, string][]) => + entries.map(([name, blurb]) => `${mention(name)}: ${blurb}`).join("\n"); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Commands") + .setDescription(withFlash(state.queryParams, "Every command is clickable.")) + .addFields( + { name: "Quick actions", value: list(quickActions) }, + { name: "Panel openers", value: list(panels) }, + ), + ], + components: [row(backButton(router, "/"), homeButton(router))], + }; +}; + +export const homeHandlers = { + panel: { get: panelGet } satisfies Handlers, + commands: { get: commandsGet } satisfies Handlers, +}; diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..1474a18 --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,47 @@ +import { deleteDataHandlers } from "@routes/deleteData"; +import { filterHandlers } from "@routes/filter"; +import { homeHandlers } from "@routes/home"; +import { modeHandlers } from "@routes/mode"; +import { recipientsHandlers } from "@routes/recipients"; +import { ringHandlers } from "@routes/ring"; +import { signupsHandlers } from "@routes/signups"; +import { RingRouter } from "@routes/types"; + +export const registerRoutes = (router: RingRouter) => { + router.route("/", homeHandlers.panel); + router.route("/commands", homeHandlers.commands); + + // scoped panels answer their bare path as the global scope, so their + // scope-switch channel select can target "{/:channelId}" and fall back to + // global when the selection is cleared + router.route(["/filter", "/filter/:scope"], filterHandlers.panel); + router.route("/filter/:scope/members", filterHandlers.members); + router.route("/filter/:scope/type", filterHandlers.type); + router.route("/filter/:scope/reset", filterHandlers.reset); + + router.route(["/recipients", "/recipients/:scope"], recipientsHandlers.panel); + router.route("/recipients/:scope/members", recipientsHandlers.members); + router.route("/recipients/:scope/clear", recipientsHandlers.clear); + router.route("/recipients/:scope/auto-ring", recipientsHandlers.autoRing); + router.route( + "/recipients/:scope/auto-ring/unset", + recipientsHandlers.autoRingUnset, + ); + + router.route("/mode", modeHandlers.panel); + + router.route("/signups", signupsHandlers.panel); + router.route("/signups/members", signupsHandlers.members); + router.route("/signups/roles", signupsHandlers.roles); + router.route("/signups/roles/remove", signupsHandlers.rolesRemove); + // registered after /signups/roles/remove so "remove" never binds :roleId + router.route("/signups/roles/:roleId", signupsHandlers.rolePage); + + router.route("/ring", ringHandlers.panel); + router.route("/ring/users", ringHandlers.users); + router.route("/ring/user", ringHandlers.user); + router.route("/ring/default", ringHandlers.default); + + router.route("/delete-data", deleteDataHandlers.panel); + router.route("/delete-data/confirm", deleteDataHandlers.confirm); +}; diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 7393c57..ff370a7 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -3,6 +3,7 @@ import { ActionRowBuilder, APIActionRowComponent, APIComponentInMessageActionRow, + ButtonBuilder, ButtonStyle, } from "discord.js"; @@ -10,11 +11,9 @@ import { Page } from "@routes/lib/paging"; import { RingButton, RingRouter } from "@routes/types"; export const row = ( - ...components: RouteButtonBuilder[] + ...components: ButtonBuilder[] ): APIActionRowComponent => - new ActionRowBuilder() - .addComponents(...components) - .toJSON(); + new ActionRowBuilder().addComponents(...components).toJSON(); export const homeButton = (router: RingRouter): RingButton => new RouteButtonBuilder(router) diff --git a/src/routes/ring.test.ts b/src/routes/ring.test.ts new file mode 100644 index 0000000..ab0f882 --- /dev/null +++ b/src/routes/ring.test.ts @@ -0,0 +1,76 @@ +import { Interaction } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { ring } from "@main/ring"; +import { ringHandlers } from "@routes/ring"; + +vi.mock("@main/ring", async (importOriginal) => ({ + ...(await importOriginal()), + ring: vi.fn(), + ringDefaultUsers: vi.fn(), +})); +vi.mock("@db/default-ringees", () => ({ + getAllDefaultRingees: vi.fn(), +})); + +const voiceChannel = { id: "vc1" }; +const makeInteraction = (inVoice: boolean) => + ({ + user: { id: "caller" }, + member: { voice: { channel: inVoice ? voiceChannel : null } }, + }) as unknown as Interaction; + +type UsersPost = NonNullable; +const state = (query: string, values?: string[]) => + ({ + params: {}, + path: "/ring/users", + queryParams: new URLSearchParams(query), + timestamp: 0, + globals: { commandIds: new Map() }, + values, + }) as unknown as Parameters[2]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("ringing while not in a voice channel flashes the join hint and rings nobody", async () => { + const result = await ringHandlers.users.post!( + undefined as never, + makeInteraction(false), + state("", ["9"]), + ); + + expect(ring).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("voice channel"); +}); + +test("a selection rings the submitted users and reports per-user outcomes", async () => { + vi.mocked(ring).mockResolvedValue([ + { userId: "9", status: "fulfilled" }, + { userId: "8", status: "rejected", error: new Error("you blocked <@8>") }, + ]); + + const result = await ringHandlers.users.post!( + undefined as never, + makeInteraction(true), + state("", ["9", "8"]), + ); + + expect(ring).toHaveBeenCalledExactlyOnceWith( + voiceChannel, + "caller", + "wants you to join", + ["9", "8"], + ); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("flash")).toContain("Ringed <@9>"); + expect(flashParams.get("flash")).toContain("you blocked <@8>"); +}); diff --git a/src/routes/ring.ts b/src/routes/ring.ts new file mode 100644 index 0000000..2c760de --- /dev/null +++ b/src/routes/ring.ts @@ -0,0 +1,175 @@ +import { + RouteButtonBuilder, + RouteUserSelectMenuBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + ButtonStyle, + EmbedBuilder, + Interaction, + VoiceBasedChannel, +} from "discord.js"; + +import { getAllDefaultRingees } from "@db/default-ringees"; +import { + getErrorMessage, + joinWithAnd, + mentionUser, + ring, + ringDefaultUsers, + UserRingResult, +} from "@main/ring"; +import { homeButton, row } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; +import { PAGE_SIZE } from "@routes/lib/paging"; +import { Handler, Handlers } from "@routes/types"; + +const COLOR = "#c58a74"; +const PANEL = "/ring"; +const NOT_IN_VC = + "RingVC needs to know which voice channel to ring people into. Join one, then run /ring again."; + +// panels outlive the voice state that was true at render time, so every +// mutation re-checks it at click time +const voiceChannelOf = (interaction: Interaction): VoiceBasedChannel | null => + interaction.member && "voice" in interaction.member + ? interaction.member.voice.channel + : null; + +const panelGet: Handler<"GET"> = async (router, interaction, state) => { + const channel = voiceChannelOf(interaction); + if (!channel) + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Ring") + .setDescription(`⚠️ ${NOT_IN_VC}`), + ], + components: [row(homeButton(router))], + }; + + const defaults = await getAllDefaultRingees(interaction.user.id, channel.id); + const description = withFlash( + state.queryParams, + `Ringing people into <#${channel.id}>.\n\n` + + (defaults.length > 0 + ? `Your default recipients here: ${joinWithAnd(defaults.map(mentionUser))}` + : "You have no default recipients here."), + ); + + return { + embeds: [ + new EmbedBuilder().setColor(COLOR).setTitle("Ring").setDescription(description), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new RouteUserSelectMenuBuilder(router) + .setMinValues(1) + .setMaxValues(PAGE_SIZE) + .setPlaceholder("Select up to 25 people to ring") + .setPattern(`${PANEL}/users`, { method: "POST" }), + ) + .toJSON(), + row( + new RouteButtonBuilder(router) + .setLabel("Ring defaults") + .setStyle(ButtonStyle.Success) + .setTo(`${PANEL}/default`, { method: "POST" }), + homeButton(router), + ), + ], + }; +}; + +const ringResultsFlash = (results: UserRingResult[]) => { + const ringed = results + .filter((result) => result.status === "fulfilled") + .map((result) => mentionUser(result.userId)); + const lines = [ + ...(ringed.length > 0 ? [`Ringed ${joinWithAnd(ringed)}`] : []), + ...results + .filter((result) => result.status === "rejected") + .map( + (result) => + `Can't ring ${mentionUser(result.userId)} because ${result.error.message}`, + ), + ]; + return { + flash: lines.join("\n"), + level: ringed.length > 0 ? ("success" as const) : ("warn" as const), + }; +}; + +const ringUserIds = async ( + interaction: Interaction, + userIds: string[], +) => { + const channel = voiceChannelOf(interaction); + if (!channel) return flashRedirect(PANEL, NOT_IN_VC, "warn"); + if (userIds.length === 0) + return flashRedirect(PANEL, "Nobody was selected to ring", "warn"); + + try { + const results = await ring( + channel, + interaction.user.id, + "wants you to join", + userIds, + ); + const { flash, level } = ringResultsFlash(results); + return flashRedirect(PANEL, flash, level); + } catch (err) { + return flashRedirect( + PANEL, + `Can't ring because ${getErrorMessage(err)}`, + "warn", + ); + } +}; + +const usersPost: Handler<"POST"> = async (router, interaction, state) => + ringUserIds(interaction, state.values ?? []); + +// the /ring user:@x quick path +const userPost: Handler<"POST"> = async (router, interaction, state) => { + const userId = state.queryParams.get("id"); + return ringUserIds(interaction, userId ? [userId] : []); +}; + +const defaultPost: Handler<"POST"> = async (router, interaction, state) => { + const channel = voiceChannelOf(interaction); + if (!channel) return flashRedirect(PANEL, NOT_IN_VC, "warn"); + + try { + const results = await ringDefaultUsers( + channel, + interaction.user.id, + "wants you to join", + ); + const { flash, level } = ringResultsFlash(results); + return flashRedirect(PANEL, flash, level); + } catch (err) { + const message = getErrorMessage(err); + return message === "no default users to ring" + ? flashRedirect( + PANEL, + `You have no default ring recipients. Use the Ring recipients panel on the home page or ${commandMention(state.globals, "default_ring_recipients")} to add some`, + "warn", + ) + : flashRedirect( + PANEL, + `Can't ring your default recipients because ${message}`, + "warn", + ); + } +}; + +export const ringHandlers = { + panel: { get: panelGet } satisfies Handlers, + users: { post: usersPost } satisfies Handlers, + user: { post: userPost } satisfies Handlers, + default: { post: defaultPost } satisfies Handlers, +}; diff --git a/src/routes/signups.test.ts b/src/routes/signups.test.ts index 0687d24..33a3778 100644 --- a/src/routes/signups.test.ts +++ b/src/routes/signups.test.ts @@ -23,7 +23,7 @@ const makeInteraction = (admin: boolean) => guild: { channels: { cache: new Map([[voiceChannel.id, voiceChannel]]) } }, }) as unknown as Interaction; -type RoleCommitPost = NonNullable; +type RoleCommitPost = NonNullable; const commitState = (query: string) => ({ params: { roleId: "role1" }, @@ -39,7 +39,7 @@ beforeEach(() => { test("committing a role signup that already exists rejects with a flash", async () => { vi.mocked(addVoiceChatRole).mockResolvedValue(false); - const result = await signupsHandlers.roleCommit.post!( + const result = await signupsHandlers.rolePage.post!( undefined as never, makeInteraction(true), commitState("channel=vc1"), @@ -56,7 +56,7 @@ test("committing a role signup that already exists rejects with a flash", async test("committing a role signup succeeds and reports the mapping", async () => { vi.mocked(addVoiceChatRole).mockResolvedValue(true); - const result = await signupsHandlers.roleCommit.post!( + const result = await signupsHandlers.rolePage.post!( undefined as never, makeInteraction(true), commitState("channel=vc1"), @@ -71,7 +71,7 @@ test("committing a role signup succeeds and reports the mapping", async () => { }); test("a role signup commit without Manage Roles mutates nothing", async () => { - const result = await signupsHandlers.roleCommit.post!( + const result = await signupsHandlers.rolePage.post!( undefined as never, makeInteraction(false), commitState("channel=vc1"), diff --git a/src/routes/signups.ts b/src/routes/signups.ts index 3972b1d..a8d6e2d 100644 --- a/src/routes/signups.ts +++ b/src/routes/signups.ts @@ -451,6 +451,5 @@ export const signupsHandlers = { members: { post: membersPost } satisfies Handlers, roles: { get: rolesGet } satisfies Handlers, rolesRemove: { post: rolesRemovePost } satisfies Handlers, - rolePick: { get: rolePickGet } satisfies Handlers, - roleCommit: { post: roleCommitPost } satisfies Handlers, + rolePage: { get: rolePickGet, post: roleCommitPost } satisfies Handlers, }; From 4b6727f28b23e8cc109d94c0ef7175d8df7b8c58 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:51:08 -0400 Subject: [PATCH 07/82] rewrite commands as thin router dispatch adapters and wire router into client All 15 command names survive; subcommand trees on filter, mode, default_ring_recipients, and ring collapse into their panels. Co-Authored-By: Claude Fable 5 --- src/commands/commands.ts | 8 +- src/commands/defaultRingRecipient.ts | 326 ++------------------------- src/commands/deleteData.ts | 88 +------- src/commands/filter/block.ts | 47 +--- src/commands/filter/filter.ts | 254 +-------------------- src/commands/filter/unblock.ts | 47 +--- src/commands/filter/unwhitelist.ts | 47 +--- src/commands/filter/whitelist.ts | 47 +--- src/commands/help.ts | 85 +------ src/commands/mode.ts | 106 +-------- src/commands/quit.ts | 7 +- src/commands/ring.ts | 222 ++---------------- src/commands/signup.ts | 62 ++--- src/commands/signuprole.ts | 78 ++----- src/commands/unsignup.ts | 53 ++--- src/commands/unsignuprole.ts | 73 ++---- src/index.ts | 30 ++- src/routes/signups.ts | 16 +- src/routes/types.ts | 27 ++- 19 files changed, 210 insertions(+), 1413 deletions(-) diff --git a/src/commands/commands.ts b/src/commands/commands.ts index fdb7bdc..2832f39 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,6 +1,5 @@ import { ChatInputCommandInteraction, SharedSlashCommand } from "discord.js"; -import { CommandName } from "@commands/commandNames"; import { defaultRingRecipients } from "@commands/defaultRingRecipient"; import { deleteData } from "@commands/deleteData"; import { block } from "@commands/filter/block"; @@ -16,12 +15,15 @@ import { signup } from "@commands/signup"; import { signuprole } from "@commands/signuprole"; import { unsignup } from "@commands/unsignup"; import { unsignuprole } from "@commands/unsignuprole"; +import { RingRouter } from "@routes/types"; +// commands are thin adapters: they parse their options and dispatch into +// the router, where all business rules live export type CommandImplementation = { data: SharedSlashCommand; execute: ( + router: RingRouter, interaction: ChatInputCommandInteraction, - commandIds: Map, ) => Promise; }; @@ -35,7 +37,7 @@ export const commands: CommandImplementation[] = [ unsignuprole, quit, mode, - // filter commamnds + // filter commands block, unblock, whitelist, diff --git a/src/commands/defaultRingRecipient.ts b/src/commands/defaultRingRecipient.ts index d75bef2..8063fb5 100644 --- a/src/commands/defaultRingRecipient.ts +++ b/src/commands/defaultRingRecipient.ts @@ -1,325 +1,29 @@ import { ChannelType, ChatInputCommandInteraction, - EmbedBuilder, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { getAutoRingSetting, setAutoRing, unsetAutoRing } from "@db/auto-ring"; -import { - addDefaultRingee, - clearDefaultRingees, - getDefaultRingees, - removeDefaultRingee, -} from "@db/default-ringees"; -import { mentionUser } from "@main/ring"; +import { RingRouter } from "@routes/types"; export const defaultRingRecipients = { data: new SlashCommandBuilder() .setName("default_ring_recipients") - .setDescription("Configure your default ring recipients") - .addSubcommand((subcommand) => - subcommand - .setName("help") - .setDescription( - "Show information for the default_ring_recipients command", - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("edit") - .setDescription("Edit your default ring recipients") - .addIntegerOption((option) => - option - .setName("action") - .setDescription("Choose to add or remove a default ring recipient") - .addChoices({ name: "Add", value: 1 }, { name: "Remove", value: 0 }) - .setRequired(true), - ) - .addUserOption((option) => - option - .setName("user") - .setDescription( - "The user to add/remove as a default ring recipient", - ) - .setRequired(true), - ) - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "The channel to add/remove the default ring recipient for (global if not specified)", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("list") - .setDescription("List your default ring recipients") - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "The channel to list the default ring recipients for (global if not specified)", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("clear") - .setDescription("Clears your default ring recipients") - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "The channel to clear the default ring recipients for", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommandGroup((group) => - group - .setName("auto_ring") - .setDescription( - "Configure automatic ringing when you join a voice channel", - ) - .addSubcommand((subcommand) => - subcommand - .setName("set") - .setDescription("Set automatic ringing for a channel or globally") - .addBooleanOption((option) => - option - .setName("enabled") - .setDescription("Whether or not to enable automatic ringing") - .setRequired(true), - ) - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "The channel to set automatic ringing for (global if not specified)", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("unset") - .setDescription("Unset automatic ringing override for a channel") - .addChannelOption((option) => - option - .setName("channel") - .setDescription("The channel to unset automatic ringing") - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("get") - .setDescription("View your automatic ringing settings") - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "The channel to view automatic ringing settings for (global if not specified)", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ), + .setDescription("Configure your default ring recipients and auto-ring") + .addChannelOption((option) => + option + .setName("channel") + .setDescription("The channel whose recipients to open (global if omitted)") + .addChannelTypes(ChannelType.GuildVoice) + .setRequired(false), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - const subcommand = interaction.options.getSubcommand(); - if (subcommand === "help") { - // Show help information - interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor("#747ac5") - .setTitle("/default_ring_recipients") - .setDescription( - `This command allows you to configure your default ring recipients, which are used when running or if you have automatic ringing enabled`, - ) - .addFields( - { name: "help", value: "Show this help message" }, - { - name: "edit", - value: - "Add/remove a user as a default ring recipient, either globally, or for a specified channel", - }, - { - name: "list", - value: - "View all default ring recipients, either globally, or for a specified channel", - }, - { - name: "clear", - value: - "Clear all default ring recipients, either globally, or for a specified channel", - }, - { - name: "auto_ring set", - value: - "Turn automatic ringing on or off, either globally, or for a specified channel. If a channel is specified, then global auto_ring is overridden for that channel", - }, - { - name: "auto_ring unset", - value: "Unset automatic ringing override for a channel", - }, - { - name: "auto_ring get", - value: - "View whether automatic ringing is enabled, either globally, or for a specified channel", - }, - ), - ], - flags: [MessageFlags.Ephemeral], - }); - } else if (subcommand === "edit") { - // Add user to default ring recipients - const userToAddOrRemove = interaction.options.getUser("user", true); - const channel = interaction.options.getChannel("channel"); - const action = interaction.options.getInteger("action", true); - - if (action === 1) { - const added = await addDefaultRingee( - interaction.user.id, - channel?.id ?? null, - userToAddOrRemove.id, - ); - interaction - .reply({ - content: added - ? `${userToAddOrRemove} is now a${!channel ? " global" : ""} default ring recipient${channel ? ` for ${channel}` : ""}` - : `${userToAddOrRemove} is a${!channel ? " global" : ""} default ring recipient${channel ? ` for ${channel}` : ""}`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else { - const removed = await removeDefaultRingee( - interaction.user.id, - channel?.id ?? null, - userToAddOrRemove.id, - ); - interaction - .reply({ - content: removed - ? `${userToAddOrRemove} is no longer a${!channel ? " global" : ""} default ring recipient${channel ? ` for ${channel}` : ""}` - : `${userToAddOrRemove} is not a${!channel ? " global" : ""} default ring recipient${channel ? ` for ${channel}` : ""}`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } - } else if (subcommand === "list") { - // List default ring recipients - const channel = interaction.options.getChannel("channel"); - - const defaultRingees = await getDefaultRingees( - interaction.user.id, - channel?.id ?? null, - ); - const defaultRingeesString = - defaultRingees.length > 0 - ? defaultRingees.map(mentionUser).join("\n") - : "None"; - interaction - .reply({ - content: `__Your${!channel ? " global" : ""} default ring recipients${channel ? ` for ${channel}` : ""}__\n${defaultRingeesString}`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else if (subcommand === "clear") { - // Reset default ring recipients - const channel = interaction.options.getChannel("channel"); - - const cleared = await clearDefaultRingees( - interaction.user.id, - channel?.id ?? null, - ); - interaction - .reply({ - content: cleared - ? `Your${!channel ? " global" : ""} default ring recipients${channel ? ` for ${channel}` : ""} have been cleared.` - : `You already have no${!channel ? " global" : ""} default ring recipients${channel ? ` for ${channel}` : ""}.`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else if (subcommand === "set") { - // Enable/disable auto ringing - const enabled = interaction.options.getBoolean("enabled", true); - const channel = interaction.options.getChannel("channel"); - - const changed = await setAutoRing( - interaction.user.id, - channel?.id ?? null, - enabled, - ); - if (changed) { - interaction - .reply({ - content: - `Automatic ringing when you join a voice channel is now \`${enabled ? "enabled" : "disabled"}\`.` + - (enabled - ? `\n\nWARNING: This will cause you to ring all of your default ring recipients every time you join ${channel ?? "a voice channel"}, even if you are in \`stealth\` mode.` - : ""), - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else { - interaction - .reply({ - content: `Automatic ringing for ${channel ?? "voice channels"} is already \`${enabled ? "enabled" : "disabled"}\`.`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } - } else if (subcommand === "unset") { - // Unset auto ringing override for a channel - const channel = interaction.options.getChannel("channel", true); - - const unset = await unsetAutoRing(interaction.user.id, channel.id); - interaction - .reply({ - content: unset - ? `Automatic ringing override for ${channel} has been unset. Your global automatic ringing setting will now be used.` - : `You do not have an automatic ringing override set for ${channel}.`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else if (subcommand === "get") { - // Get auto ringing status - const channel = interaction.options.getChannel("channel"); - - const [autoRingOverride, globalAutoRing] = await Promise.all([ - channel - ? getAutoRingSetting(interaction.user.id, channel.id) - : Promise.resolve(undefined), - getAutoRingSetting(interaction.user.id, null), - ]); - const autoRingEnabled = autoRingOverride ?? globalAutoRing ?? false; - interaction - .reply({ - content: - `Default ring will${autoRingEnabled ? "" : " not"} run automatically ${channel ? `for ${channel}` : "globally"} because ` + - (channel && autoRingOverride !== undefined - ? `your override for ${channel} is set to \`${autoRingOverride ? "enabled" : "disabled"}\`` - : (channel ? `you have no override for ${channel} and ` : "") + - `your global auto ring is \`${autoRingEnabled ? "enabled" : "disabled"}\``), - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + const channel = interaction.options.getChannel("channel"); + await router.dispatch( + interaction, + `/recipients/${channel?.id ?? "global"}`, + { flags: [MessageFlags.Ephemeral] }, + ); }, }; diff --git a/src/commands/deleteData.ts b/src/commands/deleteData.ts index 6505b18..b59b1f2 100644 --- a/src/commands/deleteData.ts +++ b/src/commands/deleteData.ts @@ -1,100 +1,18 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ChatInputCommandInteraction, - EmbedBuilder, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { deleteAllUserData } from "@db/users"; +import { RingRouter } from "@routes/types"; export const deleteData = { data: new SlashCommandBuilder() .setName("delete_data") .setDescription("Delete all your data stored for this bot"), - async execute(interaction: ChatInputCommandInteraction) { - const response = await interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor("#ca2b2b") - .setTitle("Delete all Data") - .setDescription( - "Are you sure you want to delete all your data? This will remove all your filters, signups, and other account settings. This is irreversible", - ), - ], - components: [ - new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel("Delete") - .setStyle(ButtonStyle.Danger) - .setCustomId("confirm-delete-data"), - ) - .addComponents( - new ButtonBuilder() - .setLabel("Cancel") - .setStyle(ButtonStyle.Secondary) - .setCustomId("cancel-delete-data"), - ) - .toJSON(), - ], + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await router.dispatch(interaction, "/delete-data", { flags: [MessageFlags.Ephemeral], }); - - const confirmation = await response - .awaitMessageComponent({ - filter: (i) => i.user.id === interaction.user.id, - time: 60_000, - }) - .catch(() => ({ - customId: "cancel-delete-data", - update: interaction.editReply.bind(interaction), - })); - - let hadUserData = false; // check if there even was any user data to begin with - if (confirmation.customId === "confirm-delete-data") { - hadUserData = await deleteAllUserData(interaction.user.id); - } - - await confirmation.update({ - embeds: [ - new EmbedBuilder() - .setColor("#ca2b2b") - .setTitle("Delete all Data") - .setDescription( - "Are you sure you want to delete all your data? This will remove all your filters, signups, and other account settings. This is irreversible.", - ), - ], - components: [ - new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel( - confirmation.customId === "confirm-delete-data" - ? hadUserData - ? "Deleted Data" - : "No Data to Delete" - : "Delete", - ) - .setDisabled(true) - .setStyle(ButtonStyle.Danger) - .setCustomId("confirm-delete-data"), - ) - .addComponents( - new ButtonBuilder() - .setLabel( - confirmation.customId === "confirm-delete-data" - ? "Cancel" - : "Cancelled", - ) - .setDisabled(true) - .setStyle(ButtonStyle.Secondary) - .setCustomId("cancel-delete-data"), - ) - .toJSON(), - ], - }); }, }; diff --git a/src/commands/filter/block.ts b/src/commands/filter/block.ts index 78668de..3ac150e 100644 --- a/src/commands/filter/block.ts +++ b/src/commands/filter/block.ts @@ -4,8 +4,7 @@ import { SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { addFilterEntry, getFilter } from "@db/filters"; +import { RingRouter } from "@routes/types"; export const block = { data: new SlashCommandBuilder() @@ -17,42 +16,12 @@ export const block = { .setDescription("Select a user to block") .setRequired(true), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - const user = interaction.user; - const blockedUser = interaction.options.getUser("user", true); - - const globalFilter = await getFilter(user.id, null); - - if (globalFilter?.isWhitelist) { - interaction - .reply({ - content: `Your global filter is a whitelist. Either change it to a blacklist () or use instead`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (globalFilter?.entries.has(blockedUser.id)) { - interaction - .reply({ - content: `${blockedUser} is already blocked`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - // otherwise, add the user to the global filter - await addFilterEntry(user.id, null, blockedUser.id); - interaction - .reply({ - content: `${blockedUser} has been blocked`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user", true); + await router.dispatch(interaction, "/filter/global/members", { + method: "POST", + queryParams: { intent: "block", add: user.id }, + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/filter/filter.ts b/src/commands/filter/filter.ts index 952f4c2..eeb8faa 100644 --- a/src/commands/filter/filter.ts +++ b/src/commands/filter/filter.ts @@ -1,257 +1,27 @@ import { ChannelType, ChatInputCommandInteraction, - EmbedBuilder, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { - addFilterEntry, - filterType, - getFilter, - removeFilterEntry, - resetFilter, - setFilterType, -} from "@db/filters"; -import { mentionUser } from "@main/ring"; +import { RingRouter } from "@routes/types"; export const filter = { data: new SlashCommandBuilder() .setName("filter") - .setDescription( - "Configure your global filter or specified voice chat filter", - ) - .addSubcommand((subcommand) => - subcommand - .setName("help") - .setDescription("Show information for the /filter command"), - ) - .addSubcommand((subcommand) => - subcommand - .setName("get") - .setDescription("Get the global filter or specified voice chat filter") - .addChannelOption((option) => - option - .setName("channel") - .setDescription("The channel to get the filter for") - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("reset") - .setDescription("Resets a filter. Also resets to blacklist") - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "Resets this voice channel's filter. Leave blank for your global filter", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommandGroup((group) => - group - .setName("edit") - .setDescription("Edit a filter") - .addSubcommand((subcommand) => - subcommand - .setName("users") - .setDescription("Add or remove a user from the filter list") - .addIntegerOption((option) => - option - .setName("action") - .setDescription("Choose to add or remove users") - .addChoices( - { name: "Add", value: 1 }, - { name: "Remove", value: 0 }, - ) - .setRequired(true), - ) - .addUserOption((option) => - option - .setName("user") - .setDescription( - "Person that will be added or removed from the filter", - ) - .setRequired(true), - ) - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "Which channel's filter to modify. Leave blank to edit your global filter", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("type") - .setDescription("Change the filter type. WARNING: RESETS LIST") - .addStringOption((option) => - option - .setName("filter_type") - .setDescription( - "Choose the new filter type. If the filter is already that type, nothing changes", - ) - .addChoices( - { name: "Whitelist", value: "whitelist" }, - { name: "Blacklist", value: "blacklist" }, - ) - .setRequired(true), - ) - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "Which channel's filter to modify. Leave blank to edit your global filter", - ) - .addChannelTypes(ChannelType.GuildVoice) - .setRequired(false), - ), - ), + .setDescription("View and edit your global or voice channel filter") + .addChannelOption((option) => + option + .setName("channel") + .setDescription("The channel whose filter to open (global if omitted)") + .addChannelTypes(ChannelType.GuildVoice) + .setRequired(false), ), - async execute(interaction: ChatInputCommandInteraction) { - const subcommand = interaction.options.getSubcommand(); - const currentUser = interaction.user; // user who started the command + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { const channel = interaction.options.getChannel("channel"); - // "your global " or "your for #channel" - const scopeName = (noun: string) => - channel ? `your ${noun} for ${channel}` : `your global ${noun}`; - // for the start of a sentence - const scopeNameCapitalized = (noun: string) => - channel ? `Your ${noun} for ${channel}` : `Your global ${noun}`; - - if (subcommand === "help") { - // Show help information - interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor("#94ab62") - .setTitle("/filter") - .setDescription("This command allows you to configure your filters") - .addFields( - { name: "help", value: "Show this help message" }, - { - name: "edit users", - value: - "Add or remove users from either your global filter or specified channel filter", - }, - { - name: "edit type", - value: - "Change the filter type of your global filter or specified channel filter.\n*WARNING: RESETS LIST*", - }, - { - name: "get", - value: - "View either your global filter or specified channel filter", - }, - { - name: "reset", - value: - "Resets either your global filter or specified channel filter", - }, - ), - ], - flags: [MessageFlags.Ephemeral], - }); - } else if (subcommand === "get") { - const filter = await getFilter(currentUser.id, channel?.id ?? null); - const userList = filter - ? [...filter.entries] - .map((userId) => `${mentionUser(userId)}\n`) - .join("") - : ""; - interaction - .reply({ - content: `__List of people in ${scopeName(filterType(filter))}__\n${userList ? userList : "None"}`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else if (subcommand === "users") { - // modifying users list - const addOrRemove = interaction.options.getInteger("action", true); // 1 for add 0 for remove - const user = interaction.options.getUser("user", true); - - const filter = await getFilter(currentUser.id, channel?.id ?? null); - const type = filterType(filter); - - if (addOrRemove === 1) { - await addFilterEntry(currentUser.id, channel?.id ?? null, user.id); - interaction - .reply({ - content: `Added ${user} to ${scopeName(type)}`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else { - const removed = await removeFilterEntry( - currentUser.id, - channel?.id ?? null, - user.id, - ); - interaction - .reply({ - content: removed - ? `Removed ${user} from ${scopeName(type)}` - : `${user} was not in ${scopeName(type)}`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } - } else if (subcommand === "type") { - const type = interaction.options.getString("filter_type", true); // string of either "whitelist" or "blacklist" - if (type !== "whitelist" && type !== "blacklist") return; - if (channel && channel.type !== ChannelType.GuildVoice) { - interaction - .reply({ - content: `Filters are only available on voice channels`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; // stop the rest of function - } - - const filter = await getFilter(currentUser.id, channel?.id ?? null); - if (filterType(filter) === type) { - interaction - .reply({ - content: `${scopeNameCapitalized("filter")} is already a \`${type}\``, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } else { - await setFilterType( - currentUser.id, - channel?.id ?? null, - type === "whitelist", - ); - interaction - .reply({ - content: `${scopeNameCapitalized("filter")} was reset and changed to a \`${type}\``, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } - } else if (subcommand === "reset") { - const wasNotDefault = await resetFilter( - currentUser.id, - channel?.id ?? null, - ); - interaction - .reply({ - content: wasNotDefault - ? `${scopeNameCapitalized("filter")} has been reset and is now a blacklist` - : `${scopeNameCapitalized("filter")} is already the default (blacklist with no users)`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } + await router.dispatch(interaction, `/filter/${channel?.id ?? "global"}`, { + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/filter/unblock.ts b/src/commands/filter/unblock.ts index d0ed136..33914e4 100644 --- a/src/commands/filter/unblock.ts +++ b/src/commands/filter/unblock.ts @@ -4,8 +4,7 @@ import { SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { getFilter, removeFilterEntry } from "@db/filters"; +import { RingRouter } from "@routes/types"; export const unblock = { data: new SlashCommandBuilder() @@ -17,42 +16,12 @@ export const unblock = { .setDescription("Select a user to unblock") .setRequired(true), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - const user = interaction.user; - const blockedUser = interaction.options.getUser("user", true); - - const globalFilter = await getFilter(user.id, null); - - if (globalFilter?.isWhitelist) { - interaction - .reply({ - content: `Your global filter is a whitelist. Either change it to a blacklist () or use instead`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (!globalFilter?.entries.has(blockedUser.id)) { - interaction - .reply({ - content: `${blockedUser} isn't blocked`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - // otherwise, remove the user from the global filter - await removeFilterEntry(user.id, null, blockedUser.id); - interaction - .reply({ - content: `${blockedUser} has been unblocked`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user", true); + await router.dispatch(interaction, "/filter/global/members", { + method: "POST", + queryParams: { intent: "block", remove: user.id }, + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/filter/unwhitelist.ts b/src/commands/filter/unwhitelist.ts index 9cc9f82..3579af5 100644 --- a/src/commands/filter/unwhitelist.ts +++ b/src/commands/filter/unwhitelist.ts @@ -4,8 +4,7 @@ import { SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { getFilter, removeFilterEntry } from "@db/filters"; +import { RingRouter } from "@routes/types"; export const unwhitelist = { data: new SlashCommandBuilder() @@ -17,42 +16,12 @@ export const unwhitelist = { .setDescription("Select a user to unwhitelist") .setRequired(true), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - const user = interaction.user; - const whitelistUser = interaction.options.getUser("user", true); - - const globalFilter = await getFilter(user.id, null); - - if (!globalFilter?.isWhitelist) { - interaction - .reply({ - content: `Your global filter is not a whitelist. Either change it to a whitelist () or use instead`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (!globalFilter.entries.has(whitelistUser.id)) { - interaction - .reply({ - content: `${whitelistUser} isn't whitelisted`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - // otherwise, remove the user from the global filter - await removeFilterEntry(user.id, null, whitelistUser.id); - interaction - .reply({ - content: `${whitelistUser} has been unwhitelisted`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user", true); + await router.dispatch(interaction, "/filter/global/members", { + method: "POST", + queryParams: { intent: "whitelist", remove: user.id }, + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/filter/whitelist.ts b/src/commands/filter/whitelist.ts index bcde5a9..f5735a9 100644 --- a/src/commands/filter/whitelist.ts +++ b/src/commands/filter/whitelist.ts @@ -4,8 +4,7 @@ import { SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { addFilterEntry, getFilter } from "@db/filters"; +import { RingRouter } from "@routes/types"; export const whitelist = { data: new SlashCommandBuilder() @@ -17,42 +16,12 @@ export const whitelist = { .setDescription("Select a user to whitelist") .setRequired(true), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - const user = interaction.user; - const whitelistedUser = interaction.options.getUser("user", true); - - const globalFilter = await getFilter(user.id, null); - - if (!globalFilter?.isWhitelist) { - interaction - .reply({ - content: `Your global filter is not a whitelist. Either change it to a whitelist () or use instead`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (globalFilter.entries.has(whitelistedUser.id)) { - interaction - .reply({ - content: `${whitelistedUser} is already whitelisted`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - // otherwise, add the user to the global filter - await addFilterEntry(user.id, null, whitelistedUser.id); - interaction - .reply({ - content: `${whitelistedUser} has been whitelisted`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user", true); + await router.dispatch(interaction, "/filter/global/members", { + method: "POST", + queryParams: { intent: "whitelist", add: user.id }, + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/help.ts b/src/commands/help.ts index 34278f6..f9dddf7 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,93 +1,18 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ChatInputCommandInteraction, - EmbedBuilder, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; +import { RingRouter } from "@routes/types"; export const help = { data: new SlashCommandBuilder() .setName("help") .setDescription("Getting started"), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - interaction - .reply({ - embeds: [ - new EmbedBuilder() - .setColor("#b574c5") - .setTitle("Getting Started") - .setDescription( - "RingVC is a bot which tries to replicate Group Chat Voice Calls in Discord Servers", - ) - .addFields( - { - name: "Signing up", - value: `Use to start being rung when someone "starts" a call in the specified voice channel`, - }, - { - name: "Role signups", - value: `Admins can use to sign up an entire role for a voice channel. When someone joins, the role gets pinged. Use to remove a role. *Individual signups won't double-ping users who have a signed-up role*`, - }, - { - name: "Quitting", - value: `Use or to stop being rung for a voice channel`, - }, - { - name: "Ringing", - value: `What the bot was named after. Use to ping someone to join once you're in a voice channel`, - }, - { - name: "Blocking people", - value: `Don't want to be rung by someone? Use to block people, and to unblock them. *This means you won't be pinged if they "start" a voice call, however, once an unblocked person joins, you will be pinged*`, - }, - { - name: "Modes", - value: `Allows you to not ping people when joining a voice channel. Use for more info`, - }, - { - name: "Auto and Default Ringing", - value: `To configure your default ring recipients and auto ring, see `, - }, - { - name: "Other commands", - value: `Other than the basics above, see to manage your filters more in depth`, - }, - { - name: "Support Server", - value: `If something isn't working for you, or you'd like to suggest some new features, feel free to join the support server linked below! Or just say hello :)`, - }, - { - name: "Github", - value: `Check out the github linked below if you want to host your own version of this bot, or just view the code`, - }, - ), - ], - components: [ - new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel("Github") - .setStyle(ButtonStyle.Link) - .setURL("https://github.com/altrup/RingVC"), - ) - .addComponents( - new ButtonBuilder() - .setLabel("Support Server") - .setStyle(ButtonStyle.Link) - .setURL("https://discord.gg/bxBePEnndq"), - ) - .toJSON(), - ], - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await router.dispatch(interaction, "/", { + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/mode.ts b/src/commands/mode.ts index b53048f..7a3092e 100644 --- a/src/commands/mode.ts +++ b/src/commands/mode.ts @@ -1,112 +1,18 @@ import { ChatInputCommandInteraction, - EmbedBuilder, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { getUserMode, setUserMode } from "@db/users"; +import { RingRouter } from "@routes/types"; export const mode = { data: new SlashCommandBuilder() .setName("mode") - .setDescription("Change modes between invis, normal, and auto") - .addSubcommand((subcommand) => - subcommand - .setName("help") - .setDescription("Prints some information about what the modes do"), - ) - .addSubcommand((subcommand) => - subcommand - .setName("set") - .setDescription("Sets your mode") - .addStringOption((option) => - option - .setName("mode") - .setDescription("Which mode to switch to") - .addChoices( - { name: "Normal", value: "normal" }, - { name: "Stealth", value: "stealth" }, - { name: "Auto", value: "auto" }, - ) - .setRequired(true), - ), - ) - .addSubcommand((subcommand) => - subcommand.setName("get").setDescription("Displays your current mode"), - ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - // display help message - if (interaction.options.getSubcommand() === "help") { - interaction - .reply({ - embeds: [ - new EmbedBuilder() - .setColor("#F5853F") - .setTitle("Mode Info") - .setDescription( - `Use to change your mode, and to see your current mode. \n Modes determine what happens when you join a voice channel`, - ) - .addFields( - { - name: "Normal", - value: - "Joining a voice channel will ring all applicable users", - }, - { - name: "Stealth", - value: "Joining a voice channel will not ring anyone", - }, - { - name: "Auto", - value: - "Sets mode to Stealth, if you are Invisible on Discord, and Normal otherwise", - }, - ), - ], - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } - // set mode - else if (interaction.options.getSubcommand() === "set") { - const mode = interaction.options.getString("mode", true); - // the option choices only allow valid modes - if (mode !== "normal" && mode !== "stealth" && mode !== "auto") return; - - await setUserMode(interaction.user.id, mode); - interaction - .reply({ - content: `Mode set to \`${mode}\`. ${ - mode === "auto" - ? "Your mode will automatically switch to stealth when you are invisible on Discord" - : mode === "stealth" - ? "You will not ring anyone when you join a voice channel" - : "You will ring all applicable users when you join a voice channel" - }`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } - // get mode - else if (interaction.options.getSubcommand() === "get") { - const mode = await getUserMode(interaction.user.id); - interaction - .reply({ - content: `Your current mode is \`${mode}\`. ${ - mode === "auto" - ? "Your mode will automatically switch to stealth when you are invisible on Discord" - : mode === "stealth" - ? "You will not ring anyone when you join a voice channel" - : "You will ring all applicable users when you join a voice channel" - }`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - } + .setDescription("View and change what happens when you join a voice channel"), + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await router.dispatch(interaction, "/mode", { + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/quit.ts b/src/commands/quit.ts index 8a9ab64..9406ac8 100644 --- a/src/commands/quit.ts +++ b/src/commands/quit.ts @@ -1,4 +1,4 @@ -// literally the same as signup.js but with a different name +// the same behavior as unsignup, under the name people reach for import { ChannelType, ChatInputCommandInteraction, @@ -6,6 +6,7 @@ import { } from "discord.js"; import { unsignup } from "@commands/unsignup"; +import { RingRouter } from "@routes/types"; export const quit = { data: new SlashCommandBuilder() @@ -20,7 +21,7 @@ export const quit = { .addChannelTypes(ChannelType.GuildVoice) .setRequired(false), ), - async execute(interaction: ChatInputCommandInteraction) { - await unsignup.execute(interaction); + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await unsignup.execute(router, interaction); }, }; diff --git a/src/commands/ring.ts b/src/commands/ring.ts index 2ea5533..aa51f45 100644 --- a/src/commands/ring.ts +++ b/src/commands/ring.ts @@ -1,223 +1,33 @@ import { - ActionRowBuilder, ChatInputCommandInteraction, - ComponentType, - GuildMember, MessageFlags, SlashCommandBuilder, - UserSelectMenuBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { - joinWithAnd, - mentionUser, - ringDefaultUsers, - ring as ringUsers, -} from "@main/ring"; - -const ringUserSelectMenuBuilder = (disabled = false) => { - return new ActionRowBuilder() - .addComponents( - new UserSelectMenuBuilder() - .setPlaceholder("Select up to 25 users") - .setMinValues(1) - .setMaxValues(25) - .setDisabled(disabled) - .setCustomId("list-ring-users"), - ) - .toJSON(); -}; +import { RingRouter } from "@routes/types"; export const ring = { data: new SlashCommandBuilder() .setName("ring") - .setDescription("Rings specified user(s)") - .addSubcommand((subcommand) => - subcommand + .setDescription("Ring people into your voice channel") + .addUserOption((option) => + option .setName("user") - .setDescription("Ring a user") - .addUserOption((option) => - option - .setName("user") - .setDescription("Select a user") - .setRequired(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("users") - .setDescription("Ring multiple users (select after running command)"), - ) - .addSubcommand((subcommand) => - subcommand - .setName("default") - .setDescription("Ring all of your default ring recipients"), + .setDescription("Ring this user right away (leave blank for the panel)") + .setRequired(false), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - // command must be run in a guild - if (!interaction.member) { - interaction - .reply({ - content: `This command must be run in a Discord server`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - const channel = (interaction.member as GuildMember).voice.channel; - // if channel doesn't exist (user not in call) - if (!channel) { - // don't send dm - interaction - .reply({ - content: `Please join a vc first`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (interaction.options.getSubcommand() === "user") { - const user = interaction.options.getUser("user", true); - // send the user an invite link to the voice channel or text channel that the interaction creator is in - const results = await Promise.allSettled([ - interaction.deferReply({ flags: [MessageFlags.Ephemeral] }), - ringUsers(channel, interaction.user.id, "wants you to join", [user.id]), - ]); - - // if deferReply failed, then there isn't a reply to edit - if (results[0].status === "rejected") return; - // otherwise, edit the reply to update about the ring - if (results[1].status === "fulfilled") { - const result = results[1].value[0]; - interaction - .editReply({ - content: - result?.status === "fulfilled" - ? `Notified ${user}` - : `Can't ring ${mentionUser(user.id)}${result ? ` because ${result.error.message}` : ``}`, - }) - .catch(console.error); - } else { - interaction - .editReply({ - content: `Can't ring ${user} because ${results[1].reason.message}`, - }) - .catch(console.error); - } - } else if (interaction.options.getSubcommand() === "users") { - const response = await interaction.reply({ - content: `Select the users you want to ring`, - components: [ringUserSelectMenuBuilder(false)], + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user"); + if (user) { + await router.dispatch(interaction, "/ring/user", { + method: "POST", + queryParams: { id: user.id }, flags: [MessageFlags.Ephemeral], }); - - const selection = await response - .awaitMessageComponent({ - componentType: ComponentType.UserSelect, - filter: (i) => i.user.id === interaction.user.id, - time: 60_000, - }) - .catch(() => null); - - if (!selection) { - await interaction - .editReply({ - content: `Timed out, no users were ringed`, - components: [ringUserSelectMenuBuilder(true)], - }) - .catch(console.error); - return; - } - - const results = await Promise.allSettled([ - selection.update({ - content: `Ringing users`, - }), - ringUsers( - channel, - interaction.user.id, - "wants you to join", - selection.values, - ), - ]); - - if (results[1].status === "fulfilled") { - const ringedList = joinWithAnd( - results[1].value - .filter((r) => r.status === "fulfilled") - .map((r) => mentionUser(r.userId)), - ); - selection - .editReply({ - content: [ - ...(ringedList.length > 0 ? [`Ringed ${ringedList}`] : []), - ...results[1].value - .filter((r) => r.status === "rejected") - .map( - (r) => - `Can't ring ${mentionUser(r.userId)} because ${r.error.message}`, - ), - ].join("\n"), - components: [ringUserSelectMenuBuilder(true)], - }) - .catch(console.error); - } else { - selection - .editReply({ - content: `Can't ring ${joinWithAnd(selection.values.map(mentionUser))} because ${results[1].reason.message}`, - components: [ringUserSelectMenuBuilder(true)], - }) - .catch(console.error); - } - } else if (interaction.options.getSubcommand() === "default") { - // ring all default users - Promise.allSettled([ - interaction.deferReply({ flags: [MessageFlags.Ephemeral] }), - ringDefaultUsers(channel, interaction.user.id, "wants you to join"), - ]).then((results) => { - // if deferReply failed, then there isn't a reply to edit - if (results[0].status === "rejected") return; - // otherwise, edit the reply to update about the ring - if (results[1].status === "fulfilled") { - const ringedList = joinWithAnd( - results[1].value - .filter((r) => r.status === "fulfilled") - .map((r) => mentionUser(r.userId)), - ); - interaction - .editReply({ - content: [ - ...(ringedList.length > 0 ? [`Ringed ${ringedList}`] : []), - ...results[1].value - .filter((r) => r.status === "rejected") - .map( - (r) => - `Can't ring ${mentionUser(r.userId)} because ${r.error.message}`, - ), - ].join("\n"), - }) - .catch(console.error); - } else { - interaction - .editReply({ - content: - `Can't ring default users because ${results[1].reason.message}` + - ([ - "no default users to ring", - "no default users for whom you passed each other's filters", - ].includes(results[1].reason.message) - ? `. Use to add some` - : ""), - }) - .catch(console.error); - } - }); + return; } + await router.dispatch(interaction, "/ring", { + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/signup.ts b/src/commands/signup.ts index 6865893..2c9a226 100644 --- a/src/commands/signup.ts +++ b/src/commands/signup.ts @@ -1,15 +1,11 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ChannelType, ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { addVoiceChatUser } from "@db/voice-chats"; +import { RingRouter } from "@routes/types"; export const signup = { data: new SlashCommandBuilder() @@ -22,46 +18,24 @@ export const signup = { .addChannelTypes(ChannelType.GuildVoice) .setRequired(false), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + // with an explicit channel, or run bare inside a VC's text chat, keep + // the quick-signup behavior; bare anywhere else opens the panel const channel = - interaction.options.getChannel("channel") || interaction.channel; - const user = interaction.user; - if (!channel || channel.type !== ChannelType.GuildVoice) { - const moreInfo = new ButtonBuilder() - .setLabel("Text Channels in Voice Channels") - .setStyle(ButtonStyle.Link) - .setURL( - "https://support.discord.com/hc/en-us/articles/4412085582359-Text-Channels-Text-Chat-In-Voice-Channels", - ); - interaction - .reply({ - content: `Please select a channel, or run this command in the Voice Channel you want to sign up for`, - flags: [MessageFlags.Ephemeral], - components: [new ActionRowBuilder().addComponents(moreInfo).toJSON()], - }) - .catch(console.error); - return; // stop the rest of function - } - - const added = await addVoiceChatUser(channel.id, user.id); - if (!added) { - interaction - .reply({ - content: `You are already signed up for <#${channel.id}>. Use to unsignup`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; // stops the rest of the function - } - - interaction - .reply({ - content: `Signed up for <#${channel.id}>. Use to unsignup`, + interaction.options.getChannel("channel") ?? + (interaction.channel?.type === ChannelType.GuildVoice + ? interaction.channel + : null); + if (channel) { + await router.dispatch(interaction, "/signups/members", { + method: "POST", + queryParams: { add: channel.id }, flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + }); + return; + } + await router.dispatch(interaction, "/signups", { + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/signuprole.ts b/src/commands/signuprole.ts index 34d14bb..13bb85d 100644 --- a/src/commands/signuprole.ts +++ b/src/commands/signuprole.ts @@ -1,17 +1,11 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ChannelType, ChatInputCommandInteraction, - GuildMember, MessageFlags, - PermissionsBitField, SlashCommandBuilder, } from "discord.js"; -import { CommandName } from "@commands/commandNames"; -import { addVoiceChatRole } from "@db/voice-chats"; +import { RingRouter } from "@routes/types"; export const signuprole = { data: new SlashCommandBuilder() @@ -30,62 +24,24 @@ export const signuprole = { .addChannelTypes(ChannelType.GuildVoice) .setRequired(false), ), - async execute( - interaction: ChatInputCommandInteraction, - commandIds: Map, - ) { - const channel = - interaction.options.getChannel("channel") || interaction.channel; + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { const role = interaction.options.getRole("role", true); - const member = interaction.member as GuildMember; - - // Check permissions - user must have Manage Roles or be an admin - if ( - !member.permissions.has(PermissionsBitField.Flags.ManageRoles) && - !member.permissions.has(PermissionsBitField.Flags.Administrator) - ) { - interaction - .reply({ - content: `You need the "Manage Roles" permission to sign up roles for voice channels`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (!channel || channel.type !== ChannelType.GuildVoice) { - const moreInfo = new ButtonBuilder() - .setLabel("Text Channels in Voice Channels") - .setStyle(ButtonStyle.Link) - .setURL( - "https://support.discord.com/hc/en-us/articles/4412085582359-Text-Channels-Text-Chat-In-Voice-Channels", - ); - interaction - .reply({ - content: `Please select a channel, or run this command in the Voice Channel you want to sign up the role for`, - flags: [MessageFlags.Ephemeral], - components: [new ActionRowBuilder().addComponents(moreInfo).toJSON()], - }) - .catch(console.error); - return; - } - - const added = await addVoiceChatRole(channel.id, role.id); - if (!added) { - interaction - .reply({ - content: `<@&${role.id}> is already signed up for <#${channel.id}>. Use to remove it`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + const channel = + interaction.options.getChannel("channel") ?? + (interaction.channel?.type === ChannelType.GuildVoice + ? interaction.channel + : null); + // without a channel, land on the role's channel-pick page + if (!channel) { + await router.dispatch(interaction, `/signups/roles/${role.id}`, { + flags: [MessageFlags.Ephemeral], + }); return; } - - interaction - .reply({ - content: `Signed up <@&${role.id}> for <#${channel.id}>. Use to remove it`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + await router.dispatch(interaction, `/signups/roles/${role.id}`, { + method: "POST", + queryParams: { channel: channel.id }, + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/unsignup.ts b/src/commands/unsignup.ts index 7bf1dc1..9e20cb0 100644 --- a/src/commands/unsignup.ts +++ b/src/commands/unsignup.ts @@ -1,14 +1,11 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ChannelType, ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { removeVoiceChatUser } from "@db/voice-chats"; +import { RingRouter } from "@routes/types"; export const unsignup = { data: new SlashCommandBuilder() @@ -21,42 +18,22 @@ export const unsignup = { .addChannelTypes(ChannelType.GuildVoice) .setRequired(false), ), - async execute(interaction: ChatInputCommandInteraction) { + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { const channel = - interaction.options.getChannel("channel") ?? interaction.channel; - const user = interaction.user; - if (!channel || channel.type !== ChannelType.GuildVoice) { - const moreInfo = new ButtonBuilder() - .setLabel("Text Channels in Voice Channels") - .setStyle(ButtonStyle.Link) - .setURL( - "https://support.discord.com/hc/en-us/articles/4412085582359-Text-Channels-Text-Chat-In-Voice-Channels", - ); - interaction - .reply({ - content: `Please select a channel, or run this command in the Voice Channel you want to un-sign up for`, - flags: [MessageFlags.Ephemeral], - components: [new ActionRowBuilder().addComponents(moreInfo).toJSON()], - }) - .catch(console.error); - return; // stop the rest of function - } - - const removed = await removeVoiceChatUser(channel.id, user.id); - if (removed) { - interaction - .reply({ - content: `You will no longer be "rung" for <#${channel.id}>`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + interaction.options.getChannel("channel") ?? + (interaction.channel?.type === ChannelType.GuildVoice + ? interaction.channel + : null); + if (channel) { + await router.dispatch(interaction, "/signups/members", { + method: "POST", + queryParams: { remove: channel.id }, + flags: [MessageFlags.Ephemeral], + }); return; } - interaction - .reply({ - content: `You aren't signed up for <#${channel.id}>`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + await router.dispatch(interaction, "/signups", { + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/commands/unsignuprole.ts b/src/commands/unsignuprole.ts index 54fa8fe..6deffd8 100644 --- a/src/commands/unsignuprole.ts +++ b/src/commands/unsignuprole.ts @@ -1,16 +1,11 @@ import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ChannelType, ChatInputCommandInteraction, - GuildMember, MessageFlags, - PermissionsBitField, SlashCommandBuilder, } from "discord.js"; -import { removeVoiceChatRole } from "@db/voice-chats"; +import { RingRouter } from "@routes/types"; export const unsignuprole = { data: new SlashCommandBuilder() @@ -25,62 +20,22 @@ export const unsignuprole = { .addChannelOption((option) => option .setName("channel") - .setDescription("Select the channel to remove the role from") + .setDescription( + "Select the channel to remove the role from (all channels if omitted)", + ) .addChannelTypes(ChannelType.GuildVoice) .setRequired(false), ), - async execute(interaction: ChatInputCommandInteraction) { - const channel = - interaction.options.getChannel("channel") ?? interaction.channel; + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { const role = interaction.options.getRole("role", true); - const member = interaction.member as GuildMember; - - // Check permissions - user must have Manage Roles or be an admin - if ( - !member.permissions.has(PermissionsBitField.Flags.ManageRoles) && - !member.permissions.has(PermissionsBitField.Flags.Administrator) - ) { - interaction - .reply({ - content: `You need the "Manage Roles" permission to remove roles from voice channels`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - - if (!channel || channel.type !== ChannelType.GuildVoice) { - const moreInfo = new ButtonBuilder() - .setLabel("Text Channels in Voice Channels") - .setStyle(ButtonStyle.Link) - .setURL( - "https://support.discord.com/hc/en-us/articles/4412085582359-Text-Channels-Text-Chat-In-Voice-Channels", - ); - interaction - .reply({ - content: `Please select a channel, or run this command in the Voice Channel you want to remove the role from`, - flags: [MessageFlags.Ephemeral], - components: [new ActionRowBuilder().addComponents(moreInfo).toJSON()], - }) - .catch(console.error); - return; - } - - const removed = await removeVoiceChatRole(channel.id, role.id); - if (removed) { - interaction - .reply({ - content: `<@&${role.id}> will no longer be pinged for <#${channel.id}>`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); - return; - } - interaction - .reply({ - content: `<@&${role.id}> isn't signed up for <#${channel.id}>`, - flags: [MessageFlags.Ephemeral], - }) - .catch(console.error); + const channel = interaction.options.getChannel("channel"); + await router.dispatch(interaction, "/signups/roles/remove", { + method: "POST", + queryParams: { + role: role.id, + ...(channel ? { channel: channel.id } : {}), + }, + flags: [MessageFlags.Ephemeral], + }); }, }; diff --git a/src/index.ts b/src/index.ts index 76935b0..352dd49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { EmbedRouter } from "discord-embed-router"; import { ChatInputCommandInteraction, Client, @@ -14,6 +15,8 @@ import { } from "@commands/commands"; import { DISCORD_TOKEN } from "@config"; import { onVoiceChannelJoin } from "@main/ring"; +import { registerRoutes } from "@routes/index"; +import { Globals } from "@routes/types"; const client = new Client({ intents: [ @@ -24,12 +27,35 @@ const client = new Client({ partials: [Partials.Channel], }); +// the router handles every component/modal interaction its builders created; +// commands dispatch into it explicitly below +const router = new EmbedRouter(client, { name: "ringvc" }); +router.onError((err, interaction) => { + console.error(err); + if ( + interaction && + "reply" in interaction && + !interaction.replied && + !interaction.deferred + ) { + interaction + .reply({ + content: "Something went wrong handling that interaction", + flags: [MessageFlags.Ephemeral], + }) + .catch(console.error); + } +}); + +const commandIds = new Map(); +router.setGlobals({ commandIds }); +registerRoutes(router); + // load commands const commands = new Collection(); for (const command of commandsArray) { commands.set(command.data.name, command); } -const commandIds = new Map(); // When the client is ready, set status client.once("clientReady", async () => { @@ -70,7 +96,7 @@ client.on("interactionCreate", async (interaction) => { try { if (interaction instanceof ChatInputCommandInteraction) { - await command.execute(interaction, commandIds); + await command.execute(router, interaction); } } catch (error) { console.error(error); diff --git a/src/routes/signups.ts b/src/routes/signups.ts index a8d6e2d..67072a6 100644 --- a/src/routes/signups.ts +++ b/src/routes/signups.ts @@ -161,16 +161,14 @@ const membersPost: Handler<"POST"> = async (router, interaction, state) => { const added: string[] = []; const alreadySignedUp: string[] = []; for (const channelId of addsRequested) { - (await addVoiceChatUser(channelId, userId)) - ? added.push(channelId) - : alreadySignedUp.push(channelId); + if (await addVoiceChatUser(channelId, userId)) added.push(channelId); + else alreadySignedUp.push(channelId); } const removed: string[] = []; const notSignedUp: string[] = []; for (const channelId of removesRequested) { - (await removeVoiceChatUser(channelId, userId)) - ? removed.push(channelId) - : notSignedUp.push(channelId); + if (await removeVoiceChatUser(channelId, userId)) removed.push(channelId); + else notSignedUp.push(channelId); } const parts = [ @@ -350,9 +348,9 @@ const rolesRemovePost: Handler<"POST"> = async (router, interaction, state) => { const removed: typeof pairs = []; const missing: typeof pairs = []; for (const pair of pairs) { - (await removeVoiceChatRole(pair.channelId, pair.roleId)) - ? removed.push(pair) - : missing.push(pair); + if (await removeVoiceChatRole(pair.channelId, pair.roleId)) + removed.push(pair); + else missing.push(pair); } const describe = (list: typeof pairs) => diff --git a/src/routes/types.ts b/src/routes/types.ts index d7c442c..0299fac 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -16,19 +16,18 @@ export type Globals = { // paths and query params, and route handlers import their db access directly export type RingRouter = EmbedRouter; -type Session = - RingRouter extends EmbedRouter ? S : never; -type Locals = - RingRouter extends EmbedRouter ? L : never; - -export type Handler = RouteHandler< - M, - Globals, - Session, - Locals ->; -export type Handlers = RouteHandlers; +export type Handler = + RingRouter extends EmbedRouter + ? RouteHandler + : never; +export type Handlers = + RingRouter extends EmbedRouter + ? RouteHandlers + : never; // nameable alias for helper return types (declaration emit can't name the -// library's internal Unused symbol that Session/Locals infer to) -export type RingButton = RouteButtonBuilder; +// library's internal Unused symbol that Session/Locals default to) +export type RingButton = + RingRouter extends EmbedRouter + ? RouteButtonBuilder + : never; From 4cbec219eab0d3d00329d8e4b59ec4c6e107e535 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:52:18 -0400 Subject: [PATCH 08/82] run format, add test step to CI Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yaml | 2 +- src/commands/commands.ts | 2 +- src/commands/defaultRingRecipient.ts | 7 ++-- src/commands/deleteData.ts | 3 +- src/commands/filter/block.ts | 3 +- src/commands/filter/filter.ts | 3 +- src/commands/filter/unblock.ts | 3 +- src/commands/filter/unwhitelist.ts | 3 +- src/commands/filter/whitelist.ts | 3 +- src/commands/help.ts | 3 +- src/commands/mode.ts | 7 ++-- src/commands/quit.ts | 2 +- src/commands/ring.ts | 3 +- src/commands/signup.ts | 3 +- src/commands/signuprole.ts | 3 +- src/commands/unsignup.ts | 3 +- src/commands/unsignuprole.ts | 3 +- src/index.ts | 4 +- src/main/db/voice-chats.ts | 5 ++- src/routes/deleteData.test.ts | 2 +- src/routes/deleteData.ts | 15 ++++--- src/routes/filter.test.ts | 8 +--- src/routes/filter.ts | 19 ++++----- src/routes/home.ts | 12 +++--- src/routes/lib/components.ts | 5 +-- src/routes/lib/flash.test.ts | 19 ++++----- src/routes/lib/mentions.ts | 3 +- src/routes/lib/paging.test.ts | 10 ++--- src/routes/mode.test.ts | 2 +- src/routes/mode.ts | 6 +-- src/routes/recipients.test.ts | 2 +- src/routes/recipients.ts | 16 +++++--- src/routes/ring.test.ts | 2 +- src/routes/ring.ts | 20 +++++----- src/routes/signups.test.ts | 2 +- src/routes/signups.ts | 59 +++++++++++++++++----------- 36 files changed, 137 insertions(+), 130 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 36be0cf..167dc67 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - check: [lint, typecheck, format:check, build] + check: [lint, typecheck, format:check, build, test] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 2832f39..af52cb2 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,3 +1,4 @@ +import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, SharedSlashCommand } from "discord.js"; import { defaultRingRecipients } from "@commands/defaultRingRecipient"; @@ -15,7 +16,6 @@ import { signup } from "@commands/signup"; import { signuprole } from "@commands/signuprole"; import { unsignup } from "@commands/unsignup"; import { unsignuprole } from "@commands/unsignuprole"; -import { RingRouter } from "@routes/types"; // commands are thin adapters: they parse their options and dispatch into // the router, where all business rules live diff --git a/src/commands/defaultRingRecipient.ts b/src/commands/defaultRingRecipient.ts index 8063fb5..5e0e05a 100644 --- a/src/commands/defaultRingRecipient.ts +++ b/src/commands/defaultRingRecipient.ts @@ -1,3 +1,4 @@ +import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -5,8 +6,6 @@ import { SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const defaultRingRecipients = { data: new SlashCommandBuilder() .setName("default_ring_recipients") @@ -14,7 +13,9 @@ export const defaultRingRecipients = { .addChannelOption((option) => option .setName("channel") - .setDescription("The channel whose recipients to open (global if omitted)") + .setDescription( + "The channel whose recipients to open (global if omitted)", + ) .addChannelTypes(ChannelType.GuildVoice) .setRequired(false), ), diff --git a/src/commands/deleteData.ts b/src/commands/deleteData.ts index b59b1f2..46bb06a 100644 --- a/src/commands/deleteData.ts +++ b/src/commands/deleteData.ts @@ -1,11 +1,10 @@ +import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const deleteData = { data: new SlashCommandBuilder() .setName("delete_data") diff --git a/src/commands/filter/block.ts b/src/commands/filter/block.ts index 3ac150e..3450a1a 100644 --- a/src/commands/filter/block.ts +++ b/src/commands/filter/block.ts @@ -1,11 +1,10 @@ +import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const block = { data: new SlashCommandBuilder() .setName("block") diff --git a/src/commands/filter/filter.ts b/src/commands/filter/filter.ts index eeb8faa..3214891 100644 --- a/src/commands/filter/filter.ts +++ b/src/commands/filter/filter.ts @@ -1,3 +1,4 @@ +import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -5,8 +6,6 @@ import { SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const filter = { data: new SlashCommandBuilder() .setName("filter") diff --git a/src/commands/filter/unblock.ts b/src/commands/filter/unblock.ts index 33914e4..db71e5e 100644 --- a/src/commands/filter/unblock.ts +++ b/src/commands/filter/unblock.ts @@ -1,11 +1,10 @@ +import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const unblock = { data: new SlashCommandBuilder() .setName("unblock") diff --git a/src/commands/filter/unwhitelist.ts b/src/commands/filter/unwhitelist.ts index 3579af5..f05e898 100644 --- a/src/commands/filter/unwhitelist.ts +++ b/src/commands/filter/unwhitelist.ts @@ -1,11 +1,10 @@ +import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const unwhitelist = { data: new SlashCommandBuilder() .setName("unwhitelist") diff --git a/src/commands/filter/whitelist.ts b/src/commands/filter/whitelist.ts index f5735a9..3350d48 100644 --- a/src/commands/filter/whitelist.ts +++ b/src/commands/filter/whitelist.ts @@ -1,11 +1,10 @@ +import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const whitelist = { data: new SlashCommandBuilder() .setName("whitelist") diff --git a/src/commands/help.ts b/src/commands/help.ts index f9dddf7..cdc4318 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,11 +1,10 @@ +import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const help = { data: new SlashCommandBuilder() .setName("help") diff --git a/src/commands/mode.ts b/src/commands/mode.ts index 7a3092e..237c1b5 100644 --- a/src/commands/mode.ts +++ b/src/commands/mode.ts @@ -1,15 +1,16 @@ +import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const mode = { data: new SlashCommandBuilder() .setName("mode") - .setDescription("View and change what happens when you join a voice channel"), + .setDescription( + "View and change what happens when you join a voice channel", + ), async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { await router.dispatch(interaction, "/mode", { flags: [MessageFlags.Ephemeral], diff --git a/src/commands/quit.ts b/src/commands/quit.ts index 9406ac8..8a69aa9 100644 --- a/src/commands/quit.ts +++ b/src/commands/quit.ts @@ -1,4 +1,5 @@ // the same behavior as unsignup, under the name people reach for +import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -6,7 +7,6 @@ import { } from "discord.js"; import { unsignup } from "@commands/unsignup"; -import { RingRouter } from "@routes/types"; export const quit = { data: new SlashCommandBuilder() diff --git a/src/commands/ring.ts b/src/commands/ring.ts index aa51f45..ea6b9dd 100644 --- a/src/commands/ring.ts +++ b/src/commands/ring.ts @@ -1,11 +1,10 @@ +import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const ring = { data: new SlashCommandBuilder() .setName("ring") diff --git a/src/commands/signup.ts b/src/commands/signup.ts index 2c9a226..7740e23 100644 --- a/src/commands/signup.ts +++ b/src/commands/signup.ts @@ -1,3 +1,4 @@ +import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -5,8 +6,6 @@ import { SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const signup = { data: new SlashCommandBuilder() .setName("signup") diff --git a/src/commands/signuprole.ts b/src/commands/signuprole.ts index 13bb85d..5bf2aee 100644 --- a/src/commands/signuprole.ts +++ b/src/commands/signuprole.ts @@ -1,3 +1,4 @@ +import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -5,8 +6,6 @@ import { SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const signuprole = { data: new SlashCommandBuilder() .setName("signuprole") diff --git a/src/commands/unsignup.ts b/src/commands/unsignup.ts index 9e20cb0..350031e 100644 --- a/src/commands/unsignup.ts +++ b/src/commands/unsignup.ts @@ -1,3 +1,4 @@ +import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -5,8 +6,6 @@ import { SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const unsignup = { data: new SlashCommandBuilder() .setName("unsignup") diff --git a/src/commands/unsignuprole.ts b/src/commands/unsignuprole.ts index 6deffd8..c5704d8 100644 --- a/src/commands/unsignuprole.ts +++ b/src/commands/unsignuprole.ts @@ -1,3 +1,4 @@ +import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -5,8 +6,6 @@ import { SlashCommandBuilder, } from "discord.js"; -import { RingRouter } from "@routes/types"; - export const unsignuprole = { data: new SlashCommandBuilder() .setName("unsignuprole") diff --git a/src/index.ts b/src/index.ts index 352dd49..983c8bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import { registerRoutes } from "@routes/index"; +import { Globals } from "@routes/types"; import { EmbedRouter } from "discord-embed-router"; import { ChatInputCommandInteraction, @@ -15,8 +17,6 @@ import { } from "@commands/commands"; import { DISCORD_TOKEN } from "@config"; import { onVoiceChannelJoin } from "@main/ring"; -import { registerRoutes } from "@routes/index"; -import { Globals } from "@routes/types"; const client = new Client({ intents: [ diff --git a/src/main/db/voice-chats.ts b/src/main/db/voice-chats.ts index 9b3cabd..29beee6 100644 --- a/src/main/db/voice-chats.ts +++ b/src/main/db/voice-chats.ts @@ -33,7 +33,10 @@ export const getUserVoiceChatSignups = async ( userId: string, ): Promise => { const rows = rowsOf( - await db.from("voice_chat_users").select("channel_id").eq("user_id", userId), + await db + .from("voice_chat_users") + .select("channel_id") + .eq("user_id", userId), ); return rows.map((row) => row.channel_id); }; diff --git a/src/routes/deleteData.test.ts b/src/routes/deleteData.test.ts index 525cecb..e5c4b22 100644 --- a/src/routes/deleteData.test.ts +++ b/src/routes/deleteData.test.ts @@ -1,8 +1,8 @@ +import { deleteDataHandlers } from "@routes/deleteData"; import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { deleteAllUserData } from "@db/users"; -import { deleteDataHandlers } from "@routes/deleteData"; vi.mock("@db/users", () => ({ deleteAllUserData: vi.fn(), diff --git a/src/routes/deleteData.ts b/src/routes/deleteData.ts index bda8605..0aebc3f 100644 --- a/src/routes/deleteData.ts +++ b/src/routes/deleteData.ts @@ -1,7 +1,7 @@ -import { - RouteButtonBuilder, - RouteModalBuilder, -} from "discord-embed-router"; +import { row } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { Handler, Handlers } from "@routes/types"; +import { RouteButtonBuilder, RouteModalBuilder } from "discord-embed-router"; import { ButtonStyle, EmbedBuilder, @@ -11,9 +11,6 @@ import { } from "discord.js"; import { deleteAllUserData } from "@db/users"; -import { row } from "@routes/lib/components"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { Handler, Handlers } from "@routes/types"; const COLOR = "#ca2b2b"; const PANEL = "/delete-data"; @@ -81,7 +78,9 @@ const panelPost: Handler<"POST"> = async (router, interaction, state) => { const hadData = await deleteAllUserData(interaction.user.id); return flashRedirect( PANEL, - hadData ? "All your data has been deleted" : "You had no stored data to delete", + hadData + ? "All your data has been deleted" + : "You had no stored data to delete", hadData ? "success" : "warn", { done: "1" }, ); diff --git a/src/routes/filter.test.ts b/src/routes/filter.test.ts index f030020..a6d08e0 100644 --- a/src/routes/filter.test.ts +++ b/src/routes/filter.test.ts @@ -1,12 +1,8 @@ +import { filterHandlers } from "@routes/filter"; import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; -import { - addFilterEntry, - getFilter, - removeFilterEntry, -} from "@db/filters"; -import { filterHandlers } from "@routes/filter"; +import { addFilterEntry, getFilter, removeFilterEntry } from "@db/filters"; vi.mock("@db/filters", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/routes/filter.ts b/src/routes/filter.ts index ff35e36..2403b60 100644 --- a/src/routes/filter.ts +++ b/src/routes/filter.ts @@ -1,3 +1,8 @@ +import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; +import { Handler, Handlers } from "@routes/types"; import { RouteButtonBuilder, RouteChannelSelectMenuBuilder, @@ -19,11 +24,6 @@ import { setFilterType, } from "@db/filters"; import { joinWithAnd, mentionUser } from "@main/ring"; -import { homeButton, paginationRows, row } from "@routes/lib/components"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; -import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; -import { Handler, Handlers } from "@routes/types"; const COLOR = "#94ab62"; @@ -90,7 +90,9 @@ const panelGet: Handler<"GET"> = async (router, interaction, state) => { row( new RouteButtonBuilder(router) .setLabel( - type === "blacklist" ? "Switch to Whitelist" : "Switch to Blacklist", + type === "blacklist" + ? "Switch to Whitelist" + : "Switch to Blacklist", ) .setStyle(ButtonStyle.Primary) .setTo(`${panelPath(scope)}/type`, { @@ -206,9 +208,8 @@ const typePost: Handler<"POST"> = async (router, interaction, state) => { const scope = scopeOf(state.params); const channelId = channelIdOf(scope); const panel = panelPath(scope); - const to = state.queryParams.get("to") === "whitelist" - ? "whitelist" - : "blacklist"; + const to = + state.queryParams.get("to") === "whitelist" ? "whitelist" : "blacklist"; const filter = await getFilter(interaction.user.id, channelId); if (filterType(filter) === to) diff --git a/src/routes/home.ts b/src/routes/home.ts index f7e9a46..a2b8535 100644 --- a/src/routes/home.ts +++ b/src/routes/home.ts @@ -1,11 +1,11 @@ -import { RouteButtonBuilder } from "discord-embed-router"; -import { ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; - -import { CommandName } from "@commands/commandNames"; import { backButton, homeButton, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; import { Handler, Handlers } from "@routes/types"; +import { RouteButtonBuilder } from "discord-embed-router"; +import { ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; + +import { CommandName } from "@commands/commandNames"; const COLOR = "#b574c5"; const GITHUB_URL = "https://github.com/altrup/RingVC"; @@ -109,7 +109,9 @@ const commandsGet: Handler<"GET"> = (router, interaction, state) => { new EmbedBuilder() .setColor(COLOR) .setTitle("Commands") - .setDescription(withFlash(state.queryParams, "Every command is clickable.")) + .setDescription( + withFlash(state.queryParams, "Every command is clickable."), + ) .addFields( { name: "Quick actions", value: list(quickActions) }, { name: "Panel openers", value: list(panels) }, diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index ff370a7..417f611 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -1,3 +1,5 @@ +import { Page } from "@routes/lib/paging"; +import { RingButton, RingRouter } from "@routes/types"; import { RouteButtonBuilder } from "discord-embed-router"; import { ActionRowBuilder, @@ -7,9 +9,6 @@ import { ButtonStyle, } from "discord.js"; -import { Page } from "@routes/lib/paging"; -import { RingButton, RingRouter } from "@routes/types"; - export const row = ( ...components: ButtonBuilder[] ): APIActionRowComponent => diff --git a/src/routes/lib/flash.test.ts b/src/routes/lib/flash.test.ts index e50e168..a78278a 100644 --- a/src/routes/lib/flash.test.ts +++ b/src/routes/lib/flash.test.ts @@ -1,9 +1,10 @@ -import { expect, test } from "vitest"; - import { flashLine, flashRedirect } from "@routes/lib/flash"; +import { expect, test } from "vitest"; test("flashRedirect carries the flash text and level as redirect query params", () => { - expect(flashRedirect("/filter/global", "Blocked <@1>", "success")).toStrictEqual({ + expect( + flashRedirect("/filter/global", "Blocked <@1>", "success"), + ).toStrictEqual({ redirect: "/filter/global", queryParams: { flash: "Blocked <@1>", level: "success" }, }); @@ -19,12 +20,12 @@ test("flashRedirect merges extra params like the page to stay on", () => { }); test("flashLine renders the flash verbatim with an icon from the level alone", () => { - expect(flashLine(new URLSearchParams({ flash: "Done", level: "success" }))).toBe( - "✅ Done", - ); - expect(flashLine(new URLSearchParams({ flash: "Careful", level: "warn" }))).toBe( - "⚠️ Careful", - ); + expect( + flashLine(new URLSearchParams({ flash: "Done", level: "success" })), + ).toBe("✅ Done"); + expect( + flashLine(new URLSearchParams({ flash: "Careful", level: "warn" })), + ).toBe("⚠️ Careful"); }); test("flashLine returns null when no flash is present", () => { diff --git a/src/routes/lib/mentions.ts b/src/routes/lib/mentions.ts index 3169c81..22ba509 100644 --- a/src/routes/lib/mentions.ts +++ b/src/routes/lib/mentions.ts @@ -1,6 +1,7 @@ -import { CommandName } from "@commands/commandNames"; import { Globals } from "@routes/types"; +import { CommandName } from "@commands/commandNames"; + // a clickable command mention when the deployed id is known, plain text // otherwise (ids are fetched after the client is ready, so early // interactions may miss them) diff --git a/src/routes/lib/paging.test.ts b/src/routes/lib/paging.test.ts index 6f4b491..5e71d67 100644 --- a/src/routes/lib/paging.test.ts +++ b/src/routes/lib/paging.test.ts @@ -1,6 +1,5 @@ -import { expect, test } from "vitest"; - import { diffSelection, paginate } from "@routes/lib/paging"; +import { expect, test } from "vitest"; const ids = (count: number, offset = 0) => Array.from({ length: count }, (_, i) => `${i + offset + 1}`); @@ -81,7 +80,8 @@ test("a submitted value that already lives on another page is not re-added", () test("deselecting everything on a page removes the whole page only", () => { const allItems = ids(30); const { pageItems } = paginate(allItems, "0"); - expect( - diffSelection({ allItems, pageItems, submitted: [] }), - ).toStrictEqual({ added: [], removed: pageItems }); + expect(diffSelection({ allItems, pageItems, submitted: [] })).toStrictEqual({ + added: [], + removed: pageItems, + }); }); diff --git a/src/routes/mode.test.ts b/src/routes/mode.test.ts index 056131a..c94d055 100644 --- a/src/routes/mode.test.ts +++ b/src/routes/mode.test.ts @@ -1,8 +1,8 @@ +import { modeHandlers } from "@routes/mode"; import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { getUserMode, setUserMode } from "@db/users"; -import { modeHandlers } from "@routes/mode"; vi.mock("@db/users", () => ({ getUserMode: vi.fn(), diff --git a/src/routes/mode.ts b/src/routes/mode.ts index c992b6f..51019bf 100644 --- a/src/routes/mode.ts +++ b/src/routes/mode.ts @@ -1,10 +1,10 @@ +import { homeButton, row } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { Handler, Handlers } from "@routes/types"; import { RouteButtonBuilder } from "discord-embed-router"; import { ButtonStyle, EmbedBuilder } from "discord.js"; import { DiscordUserMode, getUserMode, setUserMode } from "@db/users"; -import { homeButton, row } from "@routes/lib/components"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { Handler, Handlers } from "@routes/types"; const COLOR = "#F5853F"; const PATH = "/mode"; diff --git a/src/routes/recipients.test.ts b/src/routes/recipients.test.ts index 322f1e3..e0340fd 100644 --- a/src/routes/recipients.test.ts +++ b/src/routes/recipients.test.ts @@ -1,8 +1,8 @@ +import { recipientsHandlers } from "@routes/recipients"; import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { setAutoRing, unsetAutoRing } from "@db/auto-ring"; -import { recipientsHandlers } from "@routes/recipients"; vi.mock("@db/auto-ring", () => ({ getAutoRingSetting: vi.fn(), diff --git a/src/routes/recipients.ts b/src/routes/recipients.ts index 03b4d36..f1d422f 100644 --- a/src/routes/recipients.ts +++ b/src/routes/recipients.ts @@ -1,3 +1,8 @@ +import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { channelIdOf, scopeOf } from "@routes/lib/scope"; +import { Handler, Handlers } from "@routes/types"; import { RouteButtonBuilder, RouteChannelSelectMenuBuilder, @@ -18,11 +23,6 @@ import { removeDefaultRingee, } from "@db/default-ringees"; import { joinWithAnd, mentionUser } from "@main/ring"; -import { homeButton, paginationRows, row } from "@routes/lib/components"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; -import { channelIdOf, scopeOf } from "@routes/lib/scope"; -import { Handler, Handlers } from "@routes/types"; const COLOR = "#747ac5"; @@ -41,7 +41,11 @@ const autoRingStatus = async (userId: string, channelId: string | null) => { : getAutoRingSetting(userId, channelId), getAutoRingSetting(userId, null), ]); - return { override, global: global ?? false, effective: override ?? global ?? false }; + return { + override, + global: global ?? false, + effective: override ?? global ?? false, + }; }; const panelGet: Handler<"GET"> = async (router, interaction, state) => { diff --git a/src/routes/ring.test.ts b/src/routes/ring.test.ts index ab0f882..5908ac5 100644 --- a/src/routes/ring.test.ts +++ b/src/routes/ring.test.ts @@ -1,8 +1,8 @@ +import { ringHandlers } from "@routes/ring"; import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { ring } from "@main/ring"; -import { ringHandlers } from "@routes/ring"; vi.mock("@main/ring", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/routes/ring.ts b/src/routes/ring.ts index 2c760de..6e9ecb8 100644 --- a/src/routes/ring.ts +++ b/src/routes/ring.ts @@ -1,3 +1,8 @@ +import { homeButton, row } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; +import { PAGE_SIZE } from "@routes/lib/paging"; +import { Handler, Handlers } from "@routes/types"; import { RouteButtonBuilder, RouteUserSelectMenuBuilder, @@ -19,11 +24,6 @@ import { ringDefaultUsers, UserRingResult, } from "@main/ring"; -import { homeButton, row } from "@routes/lib/components"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { commandMention } from "@routes/lib/mentions"; -import { PAGE_SIZE } from "@routes/lib/paging"; -import { Handler, Handlers } from "@routes/types"; const COLOR = "#c58a74"; const PANEL = "/ring"; @@ -61,7 +61,10 @@ const panelGet: Handler<"GET"> = async (router, interaction, state) => { return { embeds: [ - new EmbedBuilder().setColor(COLOR).setTitle("Ring").setDescription(description), + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Ring") + .setDescription(description), ], components: [ new ActionRowBuilder() @@ -103,10 +106,7 @@ const ringResultsFlash = (results: UserRingResult[]) => { }; }; -const ringUserIds = async ( - interaction: Interaction, - userIds: string[], -) => { +const ringUserIds = async (interaction: Interaction, userIds: string[]) => { const channel = voiceChannelOf(interaction); if (!channel) return flashRedirect(PANEL, NOT_IN_VC, "warn"); if (userIds.length === 0) diff --git a/src/routes/signups.test.ts b/src/routes/signups.test.ts index 33a3778..11c847b 100644 --- a/src/routes/signups.test.ts +++ b/src/routes/signups.test.ts @@ -1,8 +1,8 @@ +import { signupsHandlers } from "@routes/signups"; import { Interaction, PermissionsBitField } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { addVoiceChatRole } from "@db/voice-chats"; -import { signupsHandlers } from "@routes/signups"; vi.mock("@db/voice-chats", () => ({ getUserVoiceChatSignups: vi.fn(), diff --git a/src/routes/signups.ts b/src/routes/signups.ts index 67072a6..8ca792a 100644 --- a/src/routes/signups.ts +++ b/src/routes/signups.ts @@ -1,3 +1,12 @@ +import { + backButton, + homeButton, + paginationRows, + row, +} from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { Handler, Handlers } from "@routes/types"; import { RouteButtonBuilder, RouteChannelSelectMenuBuilder, @@ -24,15 +33,6 @@ import { removeVoiceChatUser, } from "@db/voice-chats"; import { joinWithAnd, mentionRole } from "@main/ring"; -import { - backButton, - homeButton, - paginationRows, - row, -} from "@routes/lib/components"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; -import { Handler, Handlers } from "@routes/types"; const COLOR = "#62a8ab"; const PANEL = "/signups"; @@ -41,9 +41,8 @@ const ROLES = "/signups/roles"; const mentionChannel = (channelId: string) => `<#${channelId}>`; const canManageRoleSignups = (interaction: Interaction): boolean => - interaction.memberPermissions?.has( - PermissionsBitField.Flags.ManageRoles, - ) === true || + interaction.memberPermissions?.has(PermissionsBitField.Flags.ManageRoles) === + true || interaction.memberPermissions?.has( PermissionsBitField.Flags.Administrator, ) === true; @@ -67,7 +66,10 @@ const guildOnlyRender = { // the user's signups, restricted to this guild's voice channels and sorted // by channel name for stable paging -const guildSignups = async (userId: string, guild: Guild): Promise => { +const guildSignups = async ( + userId: string, + guild: Guild, +): Promise => { const channelIds = new Set(guildVoiceChannelIds(guild)); return (await getUserVoiceChatSignups(userId)) .filter((channelId) => channelIds.has(channelId)) @@ -80,7 +82,8 @@ const guildSignups = async (userId: string, guild: Guild): Promise => const panelGet: Handler<"GET"> = async (router, interaction, state) => { const guild = interaction.guild; - if (!guild) return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!guild) + return { ...guildOnlyRender, components: [row(homeButton(router))] }; const signups = await guildSignups(interaction.user.id, guild); const { pageItems, page, pageCount } = paginate( @@ -186,7 +189,9 @@ const membersPost: Handler<"POST"> = async (router, interaction, state) => { ] : []), ...(notSignedUp.length > 0 - ? [`You aren't signed up for ${joinWithAnd(notSignedUp.map(mentionChannel))}`] + ? [ + `You aren't signed up for ${joinWithAnd(notSignedUp.map(mentionChannel))}`, + ] : []), ]; const changed = added.length > 0 || removed.length > 0; @@ -216,13 +221,16 @@ const sortedRoleSignups = async (guild: Guild) => { `${guild.roles.cache.get(roleId)?.name ?? roleId} ${guild.channels.cache.get(channelId)?.name ?? channelId}`; return (await getVoiceChatRoleSignups(guildVoiceChannelIds(guild))).sort( (a, b) => - nameOf(a.roleId, a.channelId).localeCompare(nameOf(b.roleId, b.channelId)), + nameOf(a.roleId, a.channelId).localeCompare( + nameOf(b.roleId, b.channelId), + ), ); }; const rolesGet: Handler<"GET"> = async (router, interaction, state) => { const guild = interaction.guild; - if (!guild) return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!guild) + return { ...guildOnlyRender, components: [row(homeButton(router))] }; if (!canManageRoleSignups(interaction)) return noPermissionRender(router); const mappings = await sortedRoleSignups(guild); @@ -299,7 +307,9 @@ const rolesGet: Handler<"GET"> = async (router, interaction, state) => { }; // a remove-select value decodes to "?pair=:" -const pairOf = (value: string): { roleId: string; channelId: string } | null => { +const pairOf = ( + value: string, +): { roleId: string; channelId: string } | null => { const [, query = ""] = value.split("?"); const [roleId, channelId] = new URLSearchParams(query).get("pair")?.split(":") ?? []; @@ -362,9 +372,7 @@ const rolesRemovePost: Handler<"POST"> = async (router, interaction, state) => { ); const parts = [ ...(removed.length > 0 ? [`Removed ${describe(removed)}`] : []), - ...(missing.length > 0 - ? [`Already removed: ${describe(missing)}`] - : []), + ...(missing.length > 0 ? [`Already removed: ${describe(missing)}`] : []), ]; return flashRedirect( ROLES, @@ -376,10 +384,12 @@ const rolesRemovePost: Handler<"POST"> = async (router, interaction, state) => { const rolePickGet: Handler<"GET"> = async (router, interaction, state) => { const guild = interaction.guild; - if (!guild) return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!guild) + return { ...guildOnlyRender, components: [row(homeButton(router))] }; if (!canManageRoleSignups(interaction)) return noPermissionRender(router); - const roleId = typeof state.params.roleId === "string" ? state.params.roleId : ""; + const roleId = + typeof state.params.roleId === "string" ? state.params.roleId : ""; return { embeds: [ @@ -420,7 +430,8 @@ const roleCommitPost: Handler<"POST"> = async (router, interaction, state) => { "warn", ); - const roleId = typeof state.params.roleId === "string" ? state.params.roleId : ""; + const roleId = + typeof state.params.roleId === "string" ? state.params.roleId : ""; const channelId = state.queryParams.get("channel") ?? ""; const channel = interaction.guild?.channels.cache.get(channelId); if (!channel?.isVoiceBased()) From 2d40f72bd29592d1dcdd7252ef234b2b08867240 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:55:46 -0400 Subject: [PATCH 09/82] add behavior tests for ring validation, signup pings, and auto-ring Co-Authored-By: Claude Fable 5 --- src/main/ring.test.ts | 274 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 src/main/ring.test.ts diff --git a/src/main/ring.test.ts b/src/main/ring.test.ts new file mode 100644 index 0000000..4c09286 --- /dev/null +++ b/src/main/ring.test.ts @@ -0,0 +1,274 @@ +import { User, VoiceBasedChannel } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { isAutoRingEnabled } from "@db/auto-ring"; +import { getAllDefaultRingees } from "@db/default-ringees"; +import { getFiltersForUsers, UserFilters } from "@db/filters"; +import { DiscordUserMode, getUserModes } from "@db/users"; +import { getVoiceChatSignups } from "@db/voice-chats"; +import { onVoiceChannelJoin, ring, validateRing } from "@main/ring"; + +vi.mock("@db/filters", async (importOriginal) => ({ + ...(await importOriginal()), + getFiltersForUsers: vi.fn(), +})); +vi.mock("@db/users", () => ({ + getUserModes: vi.fn(), +})); +vi.mock("@db/voice-chats", () => ({ + getVoiceChatSignups: vi.fn(), +})); +vi.mock("@db/auto-ring", () => ({ + isAutoRingEnabled: vi.fn(), +})); +vi.mock("@db/default-ringees", () => ({ + getAllDefaultRingees: vi.fn(), +})); + +type MemberOverrides = { + presenceStatus?: string; + roleIds?: string[]; +}; + +const makeMember = (id: string, overrides: MemberOverrides = {}) => ({ + id, + displayName: `name-${id}`, + presence: { status: overrides.presenceStatus ?? "online" }, + roles: { + cache: { + has: (roleId: string) => overrides.roleIds?.includes(roleId) ?? false, + }, + }, +}); + +// a stub voice channel whose guild resolves any member id; ids listed in +// cannotConnect fail the Connect permission check +const makeChannel = ({ + memberIds = [] as string[], + cannotConnect = [] as string[], + memberOverrides = {} as Record, + roleIds = [] as string[], +} = {}) => { + const memberOf = (id: string) => makeMember(id, memberOverrides[id] ?? {}); + const channel = { + id: "vc1", + name: "General", + members: new Map(memberIds.map((id) => [id, memberOf(id)])), + guild: { + members: { + resolve: memberOf, + fetch: (id: string) => Promise.resolve(memberOf(id)), + }, + roles: { + resolve: (id: string) => (roleIds.includes(id) ? { id } : null), + fetch: () => Promise.resolve(null), + }, + }, + permissionsFor: (id: string) => ({ + has: () => !cannotConnect.includes(id), + }), + send: vi.fn().mockResolvedValue(undefined), + toString: () => "#General", + }; + return channel as unknown as VoiceBasedChannel & { + send: ReturnType; + }; +}; + +const asUser = (id: string) => ({ id }) as unknown as User; + +// filters where blockerId has targetId on their global blacklist +const blockedBy = ( + blockerId: string, + targetId: string, +): Map => + new Map([ + [ + blockerId, + { + channel: null, + global: { isWhitelist: false, entries: new Set([targetId]) }, + }, + ], + ]); + +const modes = (entries: Record = {}) => + new Map(Object.entries(entries)); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getFiltersForUsers).mockResolvedValue(new Map()); + vi.mocked(getUserModes).mockResolvedValue(modes()); + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: [], + roleIds: [], + }); + vi.mocked(isAutoRingEnabled).mockResolvedValue(false); + vi.mocked(getAllDefaultRingees).mockResolvedValue([]); +}); + +// validateRing rules + +test("ringing yourself is rejected", () => { + expect(() => validateRing(makeChannel(), "a", "a", new Map())).toThrow( + "yourself", + ); +}); + +test("ringing someone who can't connect to the channel is rejected", () => { + expect(() => + validateRing(makeChannel({ cannotConnect: ["b"] }), "a", "b", new Map()), + ).toThrow("can't join"); +}); + +test("ringing someone already in the channel is rejected", () => { + expect(() => + validateRing(makeChannel({ memberIds: ["b"] }), "a", "b", new Map()), + ).toThrow("already in"); +}); + +test("ringing someone you blocked is rejected", () => { + expect(() => + validateRing(makeChannel(), "a", "b", blockedBy("a", "b")), + ).toThrow("you blocked"); +}); + +test("ringing someone who blocked you is rejected", () => { + expect(() => + validateRing(makeChannel(), "a", "b", blockedBy("b", "a")), + ).toThrow("blocked you"); +}); + +test("a ring with no objections passes", () => { + expect(() => validateRing(makeChannel(), "a", "b", new Map())).not.toThrow(); +}); + +// ring() + +test("ring pings only the ringees who pass validation and reports each result", async () => { + vi.mocked(getFiltersForUsers).mockResolvedValue(blockedBy("caller", "bad")); + const channel = makeChannel(); + + const results = await ring(channel, "caller", "wants you to join", [ + "good", + "bad", + ]); + + expect(results).toStrictEqual([ + { userId: "good", status: "fulfilled" }, + { userId: "bad", status: "rejected", error: expect.any(Error) }, + ]); + expect(channel.send).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ allowedMentions: { users: ["good"] } }), + ); +}); + +// signup pings on voice channel join + +test("a signed-up user is pinged when someone starts a call", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: ["s"], + roleIds: [], + }); + const channel = makeChannel({ memberIds: ["j"] }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ allowedMentions: { users: ["s"], roles: [] } }), + ); +}); + +test("a joiner in stealth mode pings nobody", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: ["s"], + roleIds: [], + }); + vi.mocked(getUserModes).mockResolvedValue(modes({ j: "stealth" })); + const channel = makeChannel({ memberIds: ["j"] }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).not.toHaveBeenCalled(); +}); + +test("auto mode counts as stealth while the joiner appears offline", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: ["s"], + roleIds: [], + }); + vi.mocked(getUserModes).mockResolvedValue(modes({ j: "auto" })); + const channel = makeChannel({ + memberIds: ["j"], + memberOverrides: { j: { presenceStatus: "offline" } }, + }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).not.toHaveBeenCalled(); +}); + +test("nobody is pinged when a call is already active in the channel", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: ["s"], + roleIds: ["r"], + }); + const channel = makeChannel({ memberIds: ["j", "other"], roleIds: ["r"] }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).not.toHaveBeenCalled(); +}); + +test("a signed-up user with a signed-up role is pinged via the role only", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: ["s"], + roleIds: ["r"], + }); + const channel = makeChannel({ + memberIds: ["j"], + roleIds: ["r"], + memberOverrides: { s: { roleIds: ["r"] } }, + }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ allowedMentions: { users: [], roles: ["r"] } }), + ); +}); + +// auto-ring on voice channel join + +test("auto-ring rings the joiner's default recipients when enabled", async () => { + vi.mocked(isAutoRingEnabled).mockResolvedValue(true); + vi.mocked(getAllDefaultRingees).mockResolvedValue(["d"]); + const channel = makeChannel({ memberIds: ["j"] }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + content: expect.stringContaining("wants you to join"), + allowedMentions: { users: ["d"] }, + }), + ); +}); + +test("auto-ring with no default recipients is a quiet no-op", async () => { + vi.mocked(isAutoRingEnabled).mockResolvedValue(true); + const channel = makeChannel({ memberIds: ["j"] }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).not.toHaveBeenCalled(); +}); + +test("default recipients are not auto-rung when auto-ring is disabled", async () => { + vi.mocked(getAllDefaultRingees).mockResolvedValue(["d"]); + const channel = makeChannel({ memberIds: ["j"] }); + + await onVoiceChannelJoin(channel, asUser("j")); + + expect(channel.send).not.toHaveBeenCalled(); +}); From f6f49e3c1ac209b2d567e1899c020b57120a9729 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:05:53 -0400 Subject: [PATCH 10/82] depend on discord-embed-router 1.1.0 (routes empty select submissions) Co-Authored-By: Claude Fable 5 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 82b4ab5..c8420ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "dependencies": { "@supabase/supabase-js": "^2.110.2", - "discord-embed-router": "^1.0.0", + "discord-embed-router": "^1.1.0", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", @@ -2976,9 +2976,9 @@ ] }, "node_modules/discord-embed-router": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.0.0.tgz", - "integrity": "sha512-I//lj6D6/EB+GgjE38OC2t/5m5C8i6HMEKmDRAmNg6j7wWPZ/GLNd+UYYfdNHRzZJm7QkDrXigP5rOl/MGrcGA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.1.0.tgz", + "integrity": "sha512-53q56q6Xb6uCcfXnmoHFa9ZyJJThphzykl7qbaLN5ErAjsj8LOkR8/fJMJFa9WP4nvDZFV8WHvoFyE+Cx9HH4Q==", "license": "MIT", "workspaces": [ "examples/basic-bot" diff --git a/package.json b/package.json index a3847a7..a4831ae 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@supabase/supabase-js": "^2.110.2", - "discord-embed-router": "^1.0.0", + "discord-embed-router": "^1.1.0", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", From 3e5c8637da417a169b604a1850a6dd152eed422a Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:23:20 -0400 Subject: [PATCH 11/82] drop the redundant back button on /commands (duplicated the home button's custom_id) Co-Authored-By: Claude Fable 5 --- src/routes/home.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/home.ts b/src/routes/home.ts index a2b8535..4dbea3f 100644 --- a/src/routes/home.ts +++ b/src/routes/home.ts @@ -1,4 +1,4 @@ -import { backButton, homeButton, row } from "@routes/lib/components"; +import { homeButton, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; import { Handler, Handlers } from "@routes/types"; @@ -117,7 +117,7 @@ const commandsGet: Handler<"GET"> = (router, interaction, state) => { { name: "Panel openers", value: list(panels) }, ), ], - components: [row(backButton(router, "/"), homeButton(router))], + components: [row(homeButton(router))], }; }; From cfd0094002c9d8cfbd4371ecf8062e19e75460f3 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:27:05 -0400 Subject: [PATCH 12/82] require typed confirmation for filter reset and recipients reset Both destructive buttons share the label Reset and open a confirmation modal (shared helper, also adopted by delete-data). Co-Authored-By: Claude Fable 5 --- src/routes/deleteData.ts | 37 ++++++++++---------------------- src/routes/filter.test.ts | 40 ++++++++++++++++++++++++++++++++++- src/routes/filter.ts | 18 ++++++++++++++-- src/routes/lib/confirm.ts | 29 +++++++++++++++++++++++++ src/routes/recipients.test.ts | 34 +++++++++++++++++++++++++++++ src/routes/recipients.ts | 20 +++++++++++++++--- src/routes/types.ts | 5 +++++ 7 files changed, 151 insertions(+), 32 deletions(-) create mode 100644 src/routes/lib/confirm.ts diff --git a/src/routes/deleteData.ts b/src/routes/deleteData.ts index 0aebc3f..3f54e16 100644 --- a/src/routes/deleteData.ts +++ b/src/routes/deleteData.ts @@ -1,14 +1,9 @@ import { row } from "@routes/lib/components"; +import { confirmed, confirmModal } from "@routes/lib/confirm"; import { flashRedirect, withFlash } from "@routes/lib/flash"; import { Handler, Handlers } from "@routes/types"; -import { RouteButtonBuilder, RouteModalBuilder } from "discord-embed-router"; -import { - ButtonStyle, - EmbedBuilder, - LabelBuilder, - TextInputBuilder, - TextInputStyle, -} from "discord.js"; +import { RouteButtonBuilder } from "discord-embed-router"; +import { ButtonStyle, EmbedBuilder } from "discord.js"; import { deleteAllUserData } from "@db/users"; @@ -50,25 +45,15 @@ const panelGet: Handler<"GET"> = (router, interaction, state) => { }; }; -const confirmModal: Handler<"MODAL"> = (router) => - new RouteModalBuilder(router) - .setTo(PANEL, { method: "POST" }) - .setTitle("Delete all data") - .addLabelComponents( - new LabelBuilder() - .setLabel(`Type ${CONFIRMATION} to confirm`) - .setTextInputComponent( - new TextInputBuilder() - .setCustomId("confirm") - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setPlaceholder(CONFIRMATION), - ), - ); +const deleteConfirm: Handler<"MODAL"> = (router) => + confirmModal(router, { + to: PANEL, + title: "Delete all data", + word: CONFIRMATION, + }); const panelPost: Handler<"POST"> = async (router, interaction, state) => { - const confirmation = state.fields?.getTextInputValue("confirm") ?? ""; - if (confirmation !== CONFIRMATION) + if (!confirmed(state.fields, CONFIRMATION)) return flashRedirect( PANEL, "Confirmation text did not match, nothing was deleted", @@ -88,5 +73,5 @@ const panelPost: Handler<"POST"> = async (router, interaction, state) => { export const deleteDataHandlers = { panel: { get: panelGet, post: panelPost } satisfies Handlers, - confirm: { modal: confirmModal } satisfies Handlers, + confirm: { modal: deleteConfirm } satisfies Handlers, }; diff --git a/src/routes/filter.test.ts b/src/routes/filter.test.ts index a6d08e0..0fc030e 100644 --- a/src/routes/filter.test.ts +++ b/src/routes/filter.test.ts @@ -2,7 +2,12 @@ import { filterHandlers } from "@routes/filter"; import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; -import { addFilterEntry, getFilter, removeFilterEntry } from "@db/filters"; +import { + addFilterEntry, + getFilter, + removeFilterEntry, + resetFilter, +} from "@db/filters"; vi.mock("@db/filters", async (importOriginal) => ({ ...(await importOriginal()), @@ -78,6 +83,39 @@ test("a select submission applies adds and removes from the visible page togethe expect(flashParams.get("level")).toBe("success"); }); +type ResetPost = NonNullable; +const resetPost = (confirmation: string) => + filterHandlers.reset.post!(undefined as never, interaction, { + params: { scope: "global" }, + path: "/filter/global/reset", + queryParams: new URLSearchParams(), + timestamp: 0, + fields: { getTextInputValue: () => confirmation }, + } as unknown as Parameters[2]); + +test("a filter reset with matching confirmation text resets the filter", async () => { + vi.mocked(resetFilter).mockResolvedValue(true); + + const result = await resetPost("RESET"); + + expect(resetFilter).toHaveBeenCalledExactlyOnceWith("caller", null); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("success"); +}); + +test("a filter reset without matching confirmation text mutates nothing", async () => { + const result = await resetPost("nope"); + + expect(resetFilter).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("did not match"); +}); + test("blocking an already blocked user reports it without a duplicate write", async () => { vi.mocked(getFilter).mockResolvedValue({ isWhitelist: false, diff --git a/src/routes/filter.ts b/src/routes/filter.ts index 2403b60..c8a9b89 100644 --- a/src/routes/filter.ts +++ b/src/routes/filter.ts @@ -1,4 +1,5 @@ import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { confirmed, confirmModal } from "@routes/lib/confirm"; import { flashRedirect, withFlash } from "@routes/lib/flash"; import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; @@ -104,7 +105,7 @@ const panelGet: Handler<"GET"> = async (router, interaction, state) => { new RouteButtonBuilder(router) .setLabel("Reset") .setStyle(ButtonStyle.Danger) - .setTo(`${panelPath(scope)}/reset`, { method: "POST" }), + .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), homeButton(router), ), ...paginationRows(router, panelPath(scope), { page, pageCount }), @@ -227,9 +228,22 @@ const typePost: Handler<"POST"> = async (router, interaction, state) => { ); }; +const resetConfirm: Handler<"MODAL"> = (router, interaction, state) => + confirmModal(router, { + to: `${panelPath(scopeOf(state.params))}/reset`, + title: "Reset filter", + word: "RESET", + }); + const resetPost: Handler<"POST"> = async (router, interaction, state) => { const scope = scopeOf(state.params); const panel = panelPath(scope); + if (!confirmed(state.fields, "RESET")) + return flashRedirect( + panel, + "Confirmation text did not match, the filter was not reset", + "warn", + ); const wasNotDefault = await resetFilter( interaction.user.id, channelIdOf(scope), @@ -251,5 +265,5 @@ export const filterHandlers = { panel: { get: panelGet } satisfies Handlers, members: { post: membersPost } satisfies Handlers, type: { post: typePost } satisfies Handlers, - reset: { post: resetPost } satisfies Handlers, + reset: { modal: resetConfirm, post: resetPost } satisfies Handlers, }; diff --git a/src/routes/lib/confirm.ts b/src/routes/lib/confirm.ts new file mode 100644 index 0000000..89e1a6a --- /dev/null +++ b/src/routes/lib/confirm.ts @@ -0,0 +1,29 @@ +import { RingModal, RingRouter } from "@routes/types"; +import { RouteModalBuilder } from "discord-embed-router"; +import { LabelBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; + +// typed-word confirmation for destructive actions: a button opens this modal +// with method MODAL, and the POST it targets verifies the input via confirmed() +export const confirmModal = ( + router: RingRouter, + { to, title, word }: { to: string; title: string; word: string }, +): RingModal => + new RouteModalBuilder(router) + .setTo(to, { method: "POST" }) + .setTitle(title) + .addLabelComponents( + new LabelBuilder() + .setLabel(`Type ${word} to confirm`) + .setTextInputComponent( + new TextInputBuilder() + .setCustomId("confirm") + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setPlaceholder(word), + ), + ); + +export const confirmed = ( + fields: { getTextInputValue(customId: string): string } | undefined, + word: string, +) => (fields?.getTextInputValue("confirm") ?? "") === word; diff --git a/src/routes/recipients.test.ts b/src/routes/recipients.test.ts index e0340fd..96778ed 100644 --- a/src/routes/recipients.test.ts +++ b/src/routes/recipients.test.ts @@ -3,6 +3,7 @@ import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { setAutoRing, unsetAutoRing } from "@db/auto-ring"; +import { clearDefaultRingees } from "@db/default-ringees"; vi.mock("@db/auto-ring", () => ({ getAutoRingSetting: vi.fn(), @@ -31,6 +32,39 @@ beforeEach(() => { vi.clearAllMocks(); }); +type ClearPost = NonNullable; +const clearPost = (confirmation: string) => + recipientsHandlers.clear.post!(undefined as never, interaction, { + params: { scope: "global" }, + path: "/recipients/global/clear", + queryParams: new URLSearchParams(), + timestamp: 0, + fields: { getTextInputValue: () => confirmation }, + } as unknown as Parameters[2]); + +test("a recipients reset with matching confirmation text clears the list", async () => { + vi.mocked(clearDefaultRingees).mockResolvedValue(true); + + const result = await clearPost("RESET"); + + expect(clearDefaultRingees).toHaveBeenCalledExactlyOnceWith("caller", null); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("success"); +}); + +test("a recipients reset without matching confirmation text mutates nothing", async () => { + const result = await clearPost("nope"); + + expect(clearDefaultRingees).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("did not match"); +}); + test("enabling auto-ring warns that joins ring default recipients even in stealth", async () => { vi.mocked(setAutoRing).mockResolvedValue(true); diff --git a/src/routes/recipients.ts b/src/routes/recipients.ts index f1d422f..78a1642 100644 --- a/src/routes/recipients.ts +++ b/src/routes/recipients.ts @@ -1,4 +1,5 @@ import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { confirmed, confirmModal } from "@routes/lib/confirm"; import { flashRedirect, withFlash } from "@routes/lib/flash"; import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; @@ -122,9 +123,9 @@ const panelGet: Handler<"GET"> = async (router, interaction, state) => { .toJSON(), row( new RouteButtonBuilder(router) - .setLabel("Clear all") + .setLabel("Reset") .setStyle(ButtonStyle.Danger) - .setTo(`${panelPath(scope)}/clear`, { method: "POST" }), + .setTo(`${panelPath(scope)}/clear`, { method: "MODAL" }), new RouteButtonBuilder(router) .setLabel( autoRing.effective ? "Disable auto-ring" : "Enable auto-ring", @@ -191,8 +192,21 @@ const membersPost: Handler<"POST"> = async (router, interaction, state) => { ); }; +const clearConfirm: Handler<"MODAL"> = (router, interaction, state) => + confirmModal(router, { + to: `${panelPath(scopeOf(state.params))}/clear`, + title: "Reset ring recipients", + word: "RESET", + }); + const clearPost: Handler<"POST"> = async (router, interaction, state) => { const scope = scopeOf(state.params); + if (!confirmed(state.fields, "RESET")) + return flashRedirect( + panelPath(scope), + "Confirmation text did not match, your recipients were not cleared", + "warn", + ); const cleared = await clearDefaultRingees( interaction.user.id, channelIdOf(scope), @@ -268,7 +282,7 @@ const autoRingUnsetPost: Handler<"POST"> = async ( export const recipientsHandlers = { panel: { get: panelGet } satisfies Handlers, members: { post: membersPost } satisfies Handlers, - clear: { post: clearPost } satisfies Handlers, + clear: { modal: clearConfirm, post: clearPost } satisfies Handlers, autoRing: { post: autoRingPost } satisfies Handlers, autoRingUnset: { post: autoRingUnsetPost } satisfies Handlers, }; diff --git a/src/routes/types.ts b/src/routes/types.ts index 0299fac..fccbb64 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -4,6 +4,7 @@ import { RouteButtonBuilder, RouteHandler, RouteHandlers, + RouteModalBuilder, } from "discord-embed-router"; import { CommandName } from "@commands/commandNames"; @@ -31,3 +32,7 @@ export type RingButton = RingRouter extends EmbedRouter ? RouteButtonBuilder : never; +export type RingModal = + RingRouter extends EmbedRouter + ? RouteModalBuilder + : never; From 0a7e3ae122f9f501718a95729c17473bdfea6b04 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:27:05 -0400 Subject: [PATCH 13/82] give home panel buttons emojis and distinct styles Co-Authored-By: Claude Fable 5 --- src/routes/home.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/routes/home.ts b/src/routes/home.ts index 4dbea3f..136a447 100644 --- a/src/routes/home.ts +++ b/src/routes/home.ts @@ -40,29 +40,29 @@ const panelGet: Handler<"GET"> = (router, interaction, state) => { components: [ row( new RouteButtonBuilder(router) - .setLabel("Signups") - .setStyle(ButtonStyle.Secondary) + .setLabel("🔔 Signups") + .setStyle(ButtonStyle.Primary) .setTo("/signups"), new RouteButtonBuilder(router) - .setLabel("Filter") + .setLabel("🛡️ Filter") .setStyle(ButtonStyle.Secondary) .setTo("/filter/global"), new RouteButtonBuilder(router) - .setLabel("Ring recipients") + .setLabel("📣 Ring recipients") .setStyle(ButtonStyle.Secondary) .setTo("/recipients/global"), new RouteButtonBuilder(router) - .setLabel("Mode") + .setLabel("💤 Mode") .setStyle(ButtonStyle.Secondary) .setTo("/mode"), new RouteButtonBuilder(router) - .setLabel("Delete data") - .setStyle(ButtonStyle.Secondary) + .setLabel("🗑️ Delete data") + .setStyle(ButtonStyle.Danger) .setTo("/delete-data"), ), row( new RouteButtonBuilder(router) - .setLabel("Commands") + .setLabel("📖 Commands") .setStyle(ButtonStyle.Secondary) .setTo("/commands"), new ButtonBuilder() From de1b61c1e364f7c62b3c2a4ec004a7233c30e047 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:54:42 -0400 Subject: [PATCH 14/82] reorganiza route files --- src/routes/_shared.ts | 1 + src/routes/commands/get.ts | 52 ++ src/routes/delete-data/_shared.ts | 2 + src/routes/delete-data/confirm/modal.ts | 11 + .../delete-data.test.ts} | 10 +- src/routes/delete-data/get.ts | 43 ++ src/routes/delete-data/post.ts | 30 ++ src/routes/deleteData.ts | 77 --- src/routes/filter.ts | 269 ---------- src/routes/filter/[scope]/get.ts | 106 ++++ src/routes/filter/[scope]/members/post.ts | 110 +++++ src/routes/filter/[scope]/reset/modal.ts | 16 + src/routes/filter/[scope]/reset/post.ts | 38 ++ src/routes/filter/[scope]/type/post.ts | 34 ++ src/routes/filter/_shared.ts | 1 + src/routes/{ => filter}/filter.test.ts | 33 +- src/routes/{home.ts => get.ts} | 57 +-- src/routes/index.ts | 106 ++-- src/routes/mode.ts | 84 ---- src/routes/mode/_shared.ts | 28 ++ src/routes/mode/get.ts | 46 ++ src/routes/{ => mode}/mode.test.ts | 10 +- src/routes/mode/post.ts | 20 + src/routes/recipients.ts | 288 ----------- .../recipients/[scope]/auto-ring/post.ts | 37 ++ .../[scope]/auto-ring/unset/post.ts | 36 ++ src/routes/recipients/[scope]/clear/modal.ts | 16 + src/routes/recipients/[scope]/clear/post.ts | 37 ++ src/routes/recipients/[scope]/get.ts | 149 ++++++ src/routes/recipients/[scope]/members/post.ts | 55 +++ src/routes/recipients/_shared.ts | 5 + .../{ => recipients}/recipients.test.ts | 27 +- src/routes/ring.ts | 175 ------- src/routes/ring/_shared.ts | 69 +++ src/routes/ring/default/post.ts | 39 ++ src/routes/ring/get.ts | 66 +++ src/routes/{ => ring}/ring.test.ts | 10 +- src/routes/ring/user/post.ts | 13 + src/routes/ring/users/post.ts | 9 + src/routes/signups.ts | 464 ------------------ src/routes/signups/_shared.ts | 83 ++++ src/routes/signups/get.ts | 80 +++ src/routes/signups/members/post.ts | 82 ++++ src/routes/signups/roles/[roleId]/get.ts | 59 +++ src/routes/signups/roles/[roleId]/post.ts | 44 ++ src/routes/signups/roles/get.ts | 111 +++++ src/routes/signups/roles/remove/post.ts | 92 ++++ src/routes/{ => signups}/signups.test.ts | 12 +- src/routes/types.ts | 5 - 49 files changed, 1745 insertions(+), 1502 deletions(-) create mode 100644 src/routes/_shared.ts create mode 100644 src/routes/commands/get.ts create mode 100644 src/routes/delete-data/_shared.ts create mode 100644 src/routes/delete-data/confirm/modal.ts rename src/routes/{deleteData.test.ts => delete-data/delete-data.test.ts} (83%) create mode 100644 src/routes/delete-data/get.ts create mode 100644 src/routes/delete-data/post.ts delete mode 100644 src/routes/deleteData.ts delete mode 100644 src/routes/filter.ts create mode 100644 src/routes/filter/[scope]/get.ts create mode 100644 src/routes/filter/[scope]/members/post.ts create mode 100644 src/routes/filter/[scope]/reset/modal.ts create mode 100644 src/routes/filter/[scope]/reset/post.ts create mode 100644 src/routes/filter/[scope]/type/post.ts create mode 100644 src/routes/filter/_shared.ts rename src/routes/{ => filter}/filter.test.ts (88%) rename src/routes/{home.ts => get.ts} (54%) delete mode 100644 src/routes/mode.ts create mode 100644 src/routes/mode/_shared.ts create mode 100644 src/routes/mode/get.ts rename src/routes/{ => mode}/mode.test.ts (83%) create mode 100644 src/routes/mode/post.ts delete mode 100644 src/routes/recipients.ts create mode 100644 src/routes/recipients/[scope]/auto-ring/post.ts create mode 100644 src/routes/recipients/[scope]/auto-ring/unset/post.ts create mode 100644 src/routes/recipients/[scope]/clear/modal.ts create mode 100644 src/routes/recipients/[scope]/clear/post.ts create mode 100644 src/routes/recipients/[scope]/get.ts create mode 100644 src/routes/recipients/[scope]/members/post.ts create mode 100644 src/routes/recipients/_shared.ts rename src/routes/{ => recipients}/recipients.test.ts (85%) delete mode 100644 src/routes/ring.ts create mode 100644 src/routes/ring/_shared.ts create mode 100644 src/routes/ring/default/post.ts create mode 100644 src/routes/ring/get.ts rename src/routes/{ => ring}/ring.test.ts (88%) create mode 100644 src/routes/ring/user/post.ts create mode 100644 src/routes/ring/users/post.ts delete mode 100644 src/routes/signups.ts create mode 100644 src/routes/signups/_shared.ts create mode 100644 src/routes/signups/get.ts create mode 100644 src/routes/signups/members/post.ts create mode 100644 src/routes/signups/roles/[roleId]/get.ts create mode 100644 src/routes/signups/roles/[roleId]/post.ts create mode 100644 src/routes/signups/roles/get.ts create mode 100644 src/routes/signups/roles/remove/post.ts rename src/routes/{ => signups}/signups.test.ts (87%) diff --git a/src/routes/_shared.ts b/src/routes/_shared.ts new file mode 100644 index 0000000..9573375 --- /dev/null +++ b/src/routes/_shared.ts @@ -0,0 +1 @@ +export const COLOR = "#b574c5"; diff --git a/src/routes/commands/get.ts b/src/routes/commands/get.ts new file mode 100644 index 0000000..182f2c3 --- /dev/null +++ b/src/routes/commands/get.ts @@ -0,0 +1,52 @@ +import { homeButton, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; +import { Handler } from "@routes/types"; +import { EmbedBuilder } from "discord.js"; + +import { CommandName } from "@commands/commandNames"; + +import { COLOR } from "../_shared"; + +export const commandsGet: Handler<"GET"> = (router, interaction, state) => { + const mention = (name: CommandName) => commandMention(state.globals, name); + const quickActions: [CommandName, string][] = [ + ["signup", "sign up for a voice channel (bare, in a VC's text chat)"], + ["unsignup", "remove a signup"], + ["quit", "same as /unsignup"], + ["signuprole", "sign up a role for a channel (Manage Roles)"], + ["unsignuprole", "remove a role signup (Manage Roles)"], + ["block", "block someone from ringing you"], + ["unblock", "unblock them"], + ["whitelist", "allow someone through your whitelist"], + ["unwhitelist", "remove them from your whitelist"], + ["ring", "ring someone into your voice channel (with a user)"], + ]; + const panels: [CommandName, string][] = [ + ["help", "the home panel"], + ["signup", "your signups panel (bare, outside a voice channel)"], + ["filter", "your filter panel"], + ["default_ring_recipients", "ring recipients and auto-ring panel"], + ["mode", "mode panel"], + ["ring", "ring panel (bare)"], + ["delete_data", "data deletion panel"], + ]; + const list = (entries: [CommandName, string][]) => + entries.map(([name, blurb]) => `${mention(name)}: ${blurb}`).join("\n"); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Commands") + .setDescription( + withFlash(state.queryParams, "Every command is clickable."), + ) + .addFields( + { name: "Quick actions", value: list(quickActions) }, + { name: "Panel openers", value: list(panels) }, + ), + ], + components: [row(homeButton(router))], + }; +}; diff --git a/src/routes/delete-data/_shared.ts b/src/routes/delete-data/_shared.ts new file mode 100644 index 0000000..1dd1c83 --- /dev/null +++ b/src/routes/delete-data/_shared.ts @@ -0,0 +1,2 @@ +export const PANEL = "/delete-data"; +export const CONFIRMATION = "DELETE"; diff --git a/src/routes/delete-data/confirm/modal.ts b/src/routes/delete-data/confirm/modal.ts new file mode 100644 index 0000000..98facb1 --- /dev/null +++ b/src/routes/delete-data/confirm/modal.ts @@ -0,0 +1,11 @@ +import { confirmModal } from "@routes/lib/confirm"; +import { Handler } from "@routes/types"; + +import { CONFIRMATION, PANEL } from "../_shared"; + +export const deleteDataConfirmModal: Handler<"MODAL"> = (router) => + confirmModal(router, { + to: PANEL, + title: "Delete all data", + word: CONFIRMATION, + }); diff --git a/src/routes/deleteData.test.ts b/src/routes/delete-data/delete-data.test.ts similarity index 83% rename from src/routes/deleteData.test.ts rename to src/routes/delete-data/delete-data.test.ts index e5c4b22..b59656a 100644 --- a/src/routes/deleteData.test.ts +++ b/src/routes/delete-data/delete-data.test.ts @@ -1,16 +1,16 @@ -import { deleteDataHandlers } from "@routes/deleteData"; import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { deleteAllUserData } from "@db/users"; +import { deleteDataPost } from "./post"; + vi.mock("@db/users", () => ({ deleteAllUserData: vi.fn(), })); const interaction = { user: { id: "caller" } } as unknown as Interaction; -type DeletePost = NonNullable; const state = (confirmation: string) => ({ params: {}, @@ -18,7 +18,7 @@ const state = (confirmation: string) => queryParams: new URLSearchParams(), timestamp: 0, fields: { getTextInputValue: () => confirmation }, - }) as unknown as Parameters[2]; + }) as unknown as Parameters[2]; beforeEach(() => { vi.clearAllMocks(); @@ -27,7 +27,7 @@ beforeEach(() => { test("typing DELETE exactly deletes all user data", async () => { vi.mocked(deleteAllUserData).mockResolvedValue(true); - const result = await deleteDataHandlers.panel.post!( + const result = await deleteDataPost( undefined as never, interaction, state("DELETE"), @@ -43,7 +43,7 @@ test("typing DELETE exactly deletes all user data", async () => { }); test("a mismatched confirmation deletes nothing", async () => { - const result = await deleteDataHandlers.panel.post!( + const result = await deleteDataPost( undefined as never, interaction, state("delete"), diff --git a/src/routes/delete-data/get.ts b/src/routes/delete-data/get.ts new file mode 100644 index 0000000..3c78b94 --- /dev/null +++ b/src/routes/delete-data/get.ts @@ -0,0 +1,43 @@ +import { row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; +import { RouteButtonBuilder } from "discord-embed-router"; +import { ButtonStyle, EmbedBuilder } from "discord.js"; + +import { PANEL } from "./_shared"; + +const COLOR = "#ca2b2b"; + +export const deleteDataGet: Handler<"GET"> = (router, interaction, state) => { + // set by the deletion redirect: the panel has done its job, so its + // buttons render disabled + const done = state.queryParams.get("done") === "1"; + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Delete all data") + .setDescription( + withFlash( + state.queryParams, + "Are you sure you want to delete all your data? This will remove all your filters, signups, and other account settings. This is irreversible.", + ), + ), + ], + components: [ + row( + new RouteButtonBuilder(router) + .setLabel("Delete") + .setStyle(ButtonStyle.Danger) + .setDisabled(done) + .setTo(`${PANEL}/confirm`, { method: "MODAL" }), + new RouteButtonBuilder(router) + .setLabel("Cancel") + .setStyle(ButtonStyle.Secondary) + .setDisabled(done) + .setTo("/"), + ), + ], + }; +}; diff --git a/src/routes/delete-data/post.ts b/src/routes/delete-data/post.ts new file mode 100644 index 0000000..b0d10f4 --- /dev/null +++ b/src/routes/delete-data/post.ts @@ -0,0 +1,30 @@ +import { confirmed } from "@routes/lib/confirm"; +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { deleteAllUserData } from "@db/users"; + +import { CONFIRMATION, PANEL } from "./_shared"; + +export const deleteDataPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + if (!confirmed(state.fields, CONFIRMATION)) + return flashRedirect( + PANEL, + "Confirmation text did not match, nothing was deleted", + "warn", + ); + + const hadData = await deleteAllUserData(interaction.user.id); + return flashRedirect( + PANEL, + hadData + ? "All your data has been deleted" + : "You had no stored data to delete", + hadData ? "success" : "warn", + { done: "1" }, + ); +}; diff --git a/src/routes/deleteData.ts b/src/routes/deleteData.ts deleted file mode 100644 index 3f54e16..0000000 --- a/src/routes/deleteData.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { row } from "@routes/lib/components"; -import { confirmed, confirmModal } from "@routes/lib/confirm"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { Handler, Handlers } from "@routes/types"; -import { RouteButtonBuilder } from "discord-embed-router"; -import { ButtonStyle, EmbedBuilder } from "discord.js"; - -import { deleteAllUserData } from "@db/users"; - -const COLOR = "#ca2b2b"; -const PANEL = "/delete-data"; -const CONFIRMATION = "DELETE"; - -const panelGet: Handler<"GET"> = (router, interaction, state) => { - // set by the deletion redirect: the panel has done its job, so its - // buttons render disabled - const done = state.queryParams.get("done") === "1"; - - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("Delete all data") - .setDescription( - withFlash( - state.queryParams, - "Are you sure you want to delete all your data? This will remove all your filters, signups, and other account settings. This is irreversible.", - ), - ), - ], - components: [ - row( - new RouteButtonBuilder(router) - .setLabel("Delete") - .setStyle(ButtonStyle.Danger) - .setDisabled(done) - .setTo(`${PANEL}/confirm`, { method: "MODAL" }), - new RouteButtonBuilder(router) - .setLabel("Cancel") - .setStyle(ButtonStyle.Secondary) - .setDisabled(done) - .setTo("/"), - ), - ], - }; -}; - -const deleteConfirm: Handler<"MODAL"> = (router) => - confirmModal(router, { - to: PANEL, - title: "Delete all data", - word: CONFIRMATION, - }); - -const panelPost: Handler<"POST"> = async (router, interaction, state) => { - if (!confirmed(state.fields, CONFIRMATION)) - return flashRedirect( - PANEL, - "Confirmation text did not match, nothing was deleted", - "warn", - ); - - const hadData = await deleteAllUserData(interaction.user.id); - return flashRedirect( - PANEL, - hadData - ? "All your data has been deleted" - : "You had no stored data to delete", - hadData ? "success" : "warn", - { done: "1" }, - ); -}; - -export const deleteDataHandlers = { - panel: { get: panelGet, post: panelPost } satisfies Handlers, - confirm: { modal: deleteConfirm } satisfies Handlers, -}; diff --git a/src/routes/filter.ts b/src/routes/filter.ts deleted file mode 100644 index c8a9b89..0000000 --- a/src/routes/filter.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { homeButton, paginationRows, row } from "@routes/lib/components"; -import { confirmed, confirmModal } from "@routes/lib/confirm"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; -import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; -import { Handler, Handlers } from "@routes/types"; -import { - RouteButtonBuilder, - RouteChannelSelectMenuBuilder, - RouteUserSelectMenuBuilder, -} from "discord-embed-router"; -import { - ActionRowBuilder, - ButtonStyle, - ChannelType, - EmbedBuilder, -} from "discord.js"; - -import { - addFilterEntry, - filterType, - getFilter, - removeFilterEntry, - resetFilter, - setFilterType, -} from "@db/filters"; -import { joinWithAnd, mentionUser } from "@main/ring"; - -const COLOR = "#94ab62"; - -const panelPath = (scope: string) => `/filter/${scope}`; - -const panelGet: Handler<"GET"> = async (router, interaction, state) => { - const scope = scopeOf(state.params); - const channelId = channelIdOf(scope); - const filter = await getFilter(interaction.user.id, channelId); - const type = filterType(filter); - const entries = [...(filter?.entries ?? [])].sort(); - const { pageItems, page, pageCount } = paginate( - entries, - state.queryParams.get("page"), - ); - - const memberList = - pageItems.length > 0 ? pageItems.map(mentionUser).join(" ") : "None"; - const description = withFlash( - state.queryParams, - `${scopeName(scope, "filter", { capitalize: true })} is a **${type}**.\n` + - (type === "whitelist" - ? "Only the people listed here can ring you, and you only ring them." - : "The people listed here can't ring you, and you won't ring them.") + - `\n\n**Members${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:** ${memberList}`, - ); - - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle(scope === "global" ? "Your global filter" : "Channel filter") - .setDescription(description), - ], - components: [ - new ActionRowBuilder() - .addComponents( - new RouteChannelSelectMenuBuilder(router) - .setChannelTypes(ChannelType.GuildVoice) - .setMinValues(0) - .setMaxValues(1) - .setPlaceholder( - channelId - ? "Viewing a channel filter (clear for global)" - : "View a voice channel's filter", - ) - .setDefaultChannels(...(channelId ? [channelId] : [])) - .setPattern("/filter{/:channelId}"), - ) - .toJSON(), - new ActionRowBuilder() - .addComponents( - new RouteUserSelectMenuBuilder(router) - .setMinValues(0) - .setMaxValues(PAGE_SIZE) - .setPlaceholder("Edit members: select to add, deselect to remove") - .setDefaultUsers(...pageItems) - .setPattern(`${panelPath(scope)}/members`, { - method: "POST", - queryParams: { page: String(page) }, - }), - ) - .toJSON(), - row( - new RouteButtonBuilder(router) - .setLabel( - type === "blacklist" - ? "Switch to Whitelist" - : "Switch to Blacklist", - ) - .setStyle(ButtonStyle.Primary) - .setTo(`${panelPath(scope)}/type`, { - method: "POST", - queryParams: { - to: type === "blacklist" ? "whitelist" : "blacklist", - }, - }), - new RouteButtonBuilder(router) - .setLabel("Reset") - .setStyle(ButtonStyle.Danger) - .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), - homeButton(router), - ), - ...paginationRows(router, panelPath(scope), { page, pageCount }), - ], - }; -}; - -// one members editor serves the panel select and the /block, /unblock, -// /whitelist and /unwhitelist adapters; `intent` keys the guard that stops -// blocklist-style edits on a whitelist (and vice versa) so the rule exists -// exactly once -const membersPost: Handler<"POST"> = async (router, interaction, state) => { - const scope = scopeOf(state.params); - const channelId = channelIdOf(scope); - const userId = interaction.user.id; - const query = state.queryParams; - const intent = query.get("intent") ?? "edit"; - const panel = panelPath(scope); - - const filter = await getFilter(userId, channelId); - const type = filterType(filter); - - if (intent === "block" && type === "whitelist") - return flashRedirect( - panel, - "Your global filter is a whitelist. Switch it to a blacklist below, or use /whitelist and /unwhitelist instead.", - "warn", - ); - if (intent === "whitelist" && type === "blacklist") - return flashRedirect( - panel, - "Your global filter is a blacklist. Switch it to a whitelist below, or use /block and /unblock instead.", - "warn", - ); - - const entries = [...(filter?.entries ?? [])].sort(); - let addsRequested: string[]; - let removesRequested: string[]; - if (state.values) { - const { pageItems } = paginate(entries, query.get("page")); - ({ added: addsRequested, removed: removesRequested } = diffSelection({ - allItems: entries, - pageItems, - submitted: state.values, - })); - } else { - addsRequested = query.getAll("add"); - removesRequested = query.getAll("remove"); - } - - const entrySet = new Set(entries); - const toAdd = addsRequested.filter((id) => !entrySet.has(id)); - const toRemove = removesRequested.filter((id) => entrySet.has(id)); - await Promise.all([ - ...toAdd.map((id) => addFilterEntry(userId, channelId, id)), - ...toRemove.map((id) => removeFilterEntry(userId, channelId, id)), - ]); - - const changed = toAdd.length > 0 || toRemove.length > 0; - const target = mentionUser(addsRequested[0] ?? removesRequested[0] ?? ""); - let flash: string; - if (intent === "block") { - flash = - addsRequested.length > 0 - ? toAdd.length > 0 - ? `Blocked ${target}` - : `${target} is already blocked` - : toRemove.length > 0 - ? `Unblocked ${target}` - : `${target} isn't blocked`; - } else if (intent === "whitelist") { - flash = - addsRequested.length > 0 - ? toAdd.length > 0 - ? `Whitelisted ${target}` - : `${target} is already whitelisted` - : toRemove.length > 0 - ? `Removed ${target} from your whitelist` - : `${target} isn't on your whitelist`; - } else { - const parts = [ - ...(toAdd.length > 0 - ? [`Added ${joinWithAnd(toAdd.map(mentionUser))}`] - : []), - ...(toRemove.length > 0 - ? [`Removed ${joinWithAnd(toRemove.map(mentionUser))}`] - : []), - ]; - flash = - parts.length > 0 - ? `${parts.join(". ")} (${scopeName(scope, type)})` - : `No changes to ${scopeName(scope, type)}`; - } - - return flashRedirect(panel, flash, changed ? "success" : "warn", { - page: query.get("page") ?? "0", - }); -}; - -const typePost: Handler<"POST"> = async (router, interaction, state) => { - const scope = scopeOf(state.params); - const channelId = channelIdOf(scope); - const panel = panelPath(scope); - const to = - state.queryParams.get("to") === "whitelist" ? "whitelist" : "blacklist"; - - const filter = await getFilter(interaction.user.id, channelId); - if (filterType(filter) === to) - return flashRedirect( - panel, - `${scopeName(scope, "filter", { capitalize: true })} is already a ${to}`, - "warn", - ); - - await setFilterType(interaction.user.id, channelId, to === "whitelist"); - return flashRedirect( - panel, - `${scopeName(scope, "filter", { capitalize: true })} was reset and changed to a ${to}`, - "warn", - ); -}; - -const resetConfirm: Handler<"MODAL"> = (router, interaction, state) => - confirmModal(router, { - to: `${panelPath(scopeOf(state.params))}/reset`, - title: "Reset filter", - word: "RESET", - }); - -const resetPost: Handler<"POST"> = async (router, interaction, state) => { - const scope = scopeOf(state.params); - const panel = panelPath(scope); - if (!confirmed(state.fields, "RESET")) - return flashRedirect( - panel, - "Confirmation text did not match, the filter was not reset", - "warn", - ); - const wasNotDefault = await resetFilter( - interaction.user.id, - channelIdOf(scope), - ); - return wasNotDefault - ? flashRedirect( - panel, - `${scopeName(scope, "filter", { capitalize: true })} has been reset and is an empty blacklist`, - "success", - ) - : flashRedirect( - panel, - `${scopeName(scope, "filter", { capitalize: true })} is already the default (an empty blacklist)`, - "warn", - ); -}; - -export const filterHandlers = { - panel: { get: panelGet } satisfies Handlers, - members: { post: membersPost } satisfies Handlers, - type: { post: typePost } satisfies Handlers, - reset: { modal: resetConfirm, post: resetPost } satisfies Handlers, -}; diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts new file mode 100644 index 0000000..bcee272 --- /dev/null +++ b/src/routes/filter/[scope]/get.ts @@ -0,0 +1,106 @@ +import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, + RouteUserSelectMenuBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, +} from "discord.js"; + +import { filterType, getFilter } from "@db/filters"; +import { mentionUser } from "@main/ring"; + +import { panelPath } from "../_shared"; + +const COLOR = "#94ab62"; + +export const filterGet: Handler<"GET"> = async (router, interaction, state) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const filter = await getFilter(interaction.user.id, channelId); + const type = filterType(filter); + const entries = [...(filter?.entries ?? [])].sort(); + const { pageItems, page, pageCount } = paginate( + entries, + state.queryParams.get("page"), + ); + + const memberList = + pageItems.length > 0 ? pageItems.map(mentionUser).join(" ") : "None"; + const description = withFlash( + state.queryParams, + `${scopeName(scope, "filter", { capitalize: true })} is a **${type}**.\n` + + (type === "whitelist" + ? "Only the people listed here can ring you, and you only ring them." + : "The people listed here can't ring you, and you won't ring them.") + + `\n\n**Members${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:** ${memberList}`, + ); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle(scope === "global" ? "Your global filter" : "Channel filter") + .setDescription(description), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder( + channelId + ? "Viewing a channel filter (clear for global)" + : "View a voice channel's filter", + ) + .setDefaultChannels(...(channelId ? [channelId] : [])) + .setPattern("/filter{/:channelId}"), + ) + .toJSON(), + new ActionRowBuilder() + .addComponents( + new RouteUserSelectMenuBuilder(router) + .setMinValues(0) + .setMaxValues(PAGE_SIZE) + .setPlaceholder("Edit members: select to add, deselect to remove") + .setDefaultUsers(...pageItems) + .setPattern(`${panelPath(scope)}/members`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON(), + row( + new RouteButtonBuilder(router) + .setLabel( + type === "blacklist" + ? "Switch to Whitelist" + : "Switch to Blacklist", + ) + .setStyle(ButtonStyle.Primary) + .setTo(`${panelPath(scope)}/type`, { + method: "POST", + queryParams: { + to: type === "blacklist" ? "whitelist" : "blacklist", + }, + }), + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Danger) + .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), + homeButton(router), + ), + ...paginationRows(router, panelPath(scope), { page, pageCount }), + ], + }; +}; diff --git a/src/routes/filter/[scope]/members/post.ts b/src/routes/filter/[scope]/members/post.ts new file mode 100644 index 0000000..7cf1a17 --- /dev/null +++ b/src/routes/filter/[scope]/members/post.ts @@ -0,0 +1,110 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { diffSelection, paginate } from "@routes/lib/paging"; +import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { + addFilterEntry, + filterType, + getFilter, + removeFilterEntry, +} from "@db/filters"; +import { joinWithAnd, mentionUser } from "@main/ring"; + +import { panelPath } from "../../_shared"; + +// one members editor serves the panel select and the /block, /unblock, +// /whitelist and /unwhitelist adapters; `intent` keys the guard that stops +// blocklist-style edits on a whitelist (and vice versa) so the rule exists +// exactly once +export const filterMembersPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const userId = interaction.user.id; + const query = state.queryParams; + const intent = query.get("intent") ?? "edit"; + const panel = panelPath(scope); + + const filter = await getFilter(userId, channelId); + const type = filterType(filter); + + if (intent === "block" && type === "whitelist") + return flashRedirect( + panel, + "Your global filter is a whitelist. Switch it to a blacklist below, or use /whitelist and /unwhitelist instead.", + "warn", + ); + if (intent === "whitelist" && type === "blacklist") + return flashRedirect( + panel, + "Your global filter is a blacklist. Switch it to a whitelist below, or use /block and /unblock instead.", + "warn", + ); + + const entries = [...(filter?.entries ?? [])].sort(); + let addsRequested: string[]; + let removesRequested: string[]; + if (state.values) { + const { pageItems } = paginate(entries, query.get("page")); + ({ added: addsRequested, removed: removesRequested } = diffSelection({ + allItems: entries, + pageItems, + submitted: state.values, + })); + } else { + addsRequested = query.getAll("add"); + removesRequested = query.getAll("remove"); + } + + const entrySet = new Set(entries); + const toAdd = addsRequested.filter((id) => !entrySet.has(id)); + const toRemove = removesRequested.filter((id) => entrySet.has(id)); + await Promise.all([ + ...toAdd.map((id) => addFilterEntry(userId, channelId, id)), + ...toRemove.map((id) => removeFilterEntry(userId, channelId, id)), + ]); + + const changed = toAdd.length > 0 || toRemove.length > 0; + const target = mentionUser(addsRequested[0] ?? removesRequested[0] ?? ""); + let flash: string; + if (intent === "block") { + flash = + addsRequested.length > 0 + ? toAdd.length > 0 + ? `Blocked ${target}` + : `${target} is already blocked` + : toRemove.length > 0 + ? `Unblocked ${target}` + : `${target} isn't blocked`; + } else if (intent === "whitelist") { + flash = + addsRequested.length > 0 + ? toAdd.length > 0 + ? `Whitelisted ${target}` + : `${target} is already whitelisted` + : toRemove.length > 0 + ? `Removed ${target} from your whitelist` + : `${target} isn't on your whitelist`; + } else { + const parts = [ + ...(toAdd.length > 0 + ? [`Added ${joinWithAnd(toAdd.map(mentionUser))}`] + : []), + ...(toRemove.length > 0 + ? [`Removed ${joinWithAnd(toRemove.map(mentionUser))}`] + : []), + ]; + flash = + parts.length > 0 + ? `${parts.join(". ")} (${scopeName(scope, type)})` + : `No changes to ${scopeName(scope, type)}`; + } + + return flashRedirect(panel, flash, changed ? "success" : "warn", { + page: query.get("page") ?? "0", + }); +}; diff --git a/src/routes/filter/[scope]/reset/modal.ts b/src/routes/filter/[scope]/reset/modal.ts new file mode 100644 index 0000000..5219ac0 --- /dev/null +++ b/src/routes/filter/[scope]/reset/modal.ts @@ -0,0 +1,16 @@ +import { confirmModal } from "@routes/lib/confirm"; +import { scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { panelPath } from "../../_shared"; + +export const filterResetModal: Handler<"MODAL"> = ( + router, + interaction, + state, +) => + confirmModal(router, { + to: `${panelPath(scopeOf(state.params))}/reset`, + title: "Reset filter", + word: "RESET", + }); diff --git a/src/routes/filter/[scope]/reset/post.ts b/src/routes/filter/[scope]/reset/post.ts new file mode 100644 index 0000000..683abfa --- /dev/null +++ b/src/routes/filter/[scope]/reset/post.ts @@ -0,0 +1,38 @@ +import { confirmed } from "@routes/lib/confirm"; +import { flashRedirect } from "@routes/lib/flash"; +import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { resetFilter } from "@db/filters"; + +import { panelPath } from "../../_shared"; + +export const filterResetPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const panel = panelPath(scope); + if (!confirmed(state.fields, "RESET")) + return flashRedirect( + panel, + "Confirmation text did not match, the filter was not reset", + "warn", + ); + const wasNotDefault = await resetFilter( + interaction.user.id, + channelIdOf(scope), + ); + return wasNotDefault + ? flashRedirect( + panel, + `${scopeName(scope, "filter", { capitalize: true })} has been reset and is an empty blacklist`, + "success", + ) + : flashRedirect( + panel, + `${scopeName(scope, "filter", { capitalize: true })} is already the default (an empty blacklist)`, + "warn", + ); +}; diff --git a/src/routes/filter/[scope]/type/post.ts b/src/routes/filter/[scope]/type/post.ts new file mode 100644 index 0000000..57ebe17 --- /dev/null +++ b/src/routes/filter/[scope]/type/post.ts @@ -0,0 +1,34 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { filterType, getFilter, setFilterType } from "@db/filters"; + +import { panelPath } from "../../_shared"; + +export const filterTypePost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const panel = panelPath(scope); + const to = + state.queryParams.get("to") === "whitelist" ? "whitelist" : "blacklist"; + + const filter = await getFilter(interaction.user.id, channelId); + if (filterType(filter) === to) + return flashRedirect( + panel, + `${scopeName(scope, "filter", { capitalize: true })} is already a ${to}`, + "warn", + ); + + await setFilterType(interaction.user.id, channelId, to === "whitelist"); + return flashRedirect( + panel, + `${scopeName(scope, "filter", { capitalize: true })} was reset and changed to a ${to}`, + "warn", + ); +}; diff --git a/src/routes/filter/_shared.ts b/src/routes/filter/_shared.ts new file mode 100644 index 0000000..d46e846 --- /dev/null +++ b/src/routes/filter/_shared.ts @@ -0,0 +1 @@ +export const panelPath = (scope: string) => `/filter/${scope}`; diff --git a/src/routes/filter.test.ts b/src/routes/filter/filter.test.ts similarity index 88% rename from src/routes/filter.test.ts rename to src/routes/filter/filter.test.ts index 0fc030e..408a4d8 100644 --- a/src/routes/filter.test.ts +++ b/src/routes/filter/filter.test.ts @@ -1,4 +1,3 @@ -import { filterHandlers } from "@routes/filter"; import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; @@ -9,6 +8,9 @@ import { resetFilter, } from "@db/filters"; +import { filterMembersPost } from "./[scope]/members/post"; +import { filterResetPost } from "./[scope]/reset/post"; + vi.mock("@db/filters", async (importOriginal) => ({ ...(await importOriginal()), getFilter: vi.fn(), @@ -20,11 +22,7 @@ vi.mock("@db/filters", async (importOriginal) => ({ const interaction = { user: { id: "caller" } } as unknown as Interaction; -type MembersPost = NonNullable; -const membersState = ( - query: string, - values?: string[], -): Parameters[2] => +const membersState = (query: string, values?: string[]) => ({ params: { scope: "global" }, path: "/filter/global/members", @@ -32,15 +30,24 @@ const membersState = ( timestamp: 0, globals: { commandIds: new Map() }, values, - }) as unknown as Parameters[2]; + }) as unknown as Parameters[2]; const membersPost = (query: string, values?: string[]) => - filterHandlers.members.post!( + filterMembersPost( undefined as never, interaction, membersState(query, values), ); +const resetPost = (confirmation: string) => + filterResetPost(undefined as never, interaction, { + params: { scope: "global" }, + path: "/filter/global/reset", + queryParams: new URLSearchParams(), + timestamp: 0, + fields: { getTextInputValue: () => confirmation }, + } as unknown as Parameters[2]); + beforeEach(() => { vi.clearAllMocks(); }); @@ -83,16 +90,6 @@ test("a select submission applies adds and removes from the visible page togethe expect(flashParams.get("level")).toBe("success"); }); -type ResetPost = NonNullable; -const resetPost = (confirmation: string) => - filterHandlers.reset.post!(undefined as never, interaction, { - params: { scope: "global" }, - path: "/filter/global/reset", - queryParams: new URLSearchParams(), - timestamp: 0, - fields: { getTextInputValue: () => confirmation }, - } as unknown as Parameters[2]); - test("a filter reset with matching confirmation text resets the filter", async () => { vi.mocked(resetFilter).mockResolvedValue(true); diff --git a/src/routes/home.ts b/src/routes/get.ts similarity index 54% rename from src/routes/home.ts rename to src/routes/get.ts index 136a447..4d063a1 100644 --- a/src/routes/home.ts +++ b/src/routes/get.ts @@ -1,17 +1,18 @@ -import { homeButton, row } from "@routes/lib/components"; +import { row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; -import { Handler, Handlers } from "@routes/types"; +import { Handler } from "@routes/types"; import { RouteButtonBuilder } from "discord-embed-router"; import { ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; import { CommandName } from "@commands/commandNames"; -const COLOR = "#b574c5"; +import { COLOR } from "./_shared"; + const GITHUB_URL = "https://github.com/altrup/RingVC"; const SUPPORT_URL = "https://discord.gg/bxBePEnndq"; -const panelGet: Handler<"GET"> = (router, interaction, state) => { +export const homeGet: Handler<"GET"> = (router, interaction, state) => { const mention = (name: CommandName) => commandMention(state.globals, name); return { @@ -77,51 +78,3 @@ const panelGet: Handler<"GET"> = (router, interaction, state) => { ], }; }; - -const commandsGet: Handler<"GET"> = (router, interaction, state) => { - const mention = (name: CommandName) => commandMention(state.globals, name); - const quickActions: [CommandName, string][] = [ - ["signup", "sign up for a voice channel (bare, in a VC's text chat)"], - ["unsignup", "remove a signup"], - ["quit", "same as /unsignup"], - ["signuprole", "sign up a role for a channel (Manage Roles)"], - ["unsignuprole", "remove a role signup (Manage Roles)"], - ["block", "block someone from ringing you"], - ["unblock", "unblock them"], - ["whitelist", "allow someone through your whitelist"], - ["unwhitelist", "remove them from your whitelist"], - ["ring", "ring someone into your voice channel (with a user)"], - ]; - const panels: [CommandName, string][] = [ - ["help", "the home panel"], - ["signup", "your signups panel (bare, outside a voice channel)"], - ["filter", "your filter panel"], - ["default_ring_recipients", "ring recipients and auto-ring panel"], - ["mode", "mode panel"], - ["ring", "ring panel (bare)"], - ["delete_data", "data deletion panel"], - ]; - const list = (entries: [CommandName, string][]) => - entries.map(([name, blurb]) => `${mention(name)}: ${blurb}`).join("\n"); - - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("Commands") - .setDescription( - withFlash(state.queryParams, "Every command is clickable."), - ) - .addFields( - { name: "Quick actions", value: list(quickActions) }, - { name: "Panel openers", value: list(panels) }, - ), - ], - components: [row(homeButton(router))], - }; -}; - -export const homeHandlers = { - panel: { get: panelGet } satisfies Handlers, - commands: { get: commandsGet } satisfies Handlers, -}; diff --git a/src/routes/index.ts b/src/routes/index.ts index 1474a18..3b0c721 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,47 +1,81 @@ -import { deleteDataHandlers } from "@routes/deleteData"; -import { filterHandlers } from "@routes/filter"; -import { homeHandlers } from "@routes/home"; -import { modeHandlers } from "@routes/mode"; -import { recipientsHandlers } from "@routes/recipients"; -import { ringHandlers } from "@routes/ring"; -import { signupsHandlers } from "@routes/signups"; import { RingRouter } from "@routes/types"; +import { commandsGet } from "./commands/get"; +import { deleteDataConfirmModal } from "./delete-data/confirm/modal"; +import { deleteDataGet } from "./delete-data/get"; +import { deleteDataPost } from "./delete-data/post"; +import { filterGet } from "./filter/[scope]/get"; +import { filterMembersPost } from "./filter/[scope]/members/post"; +import { filterResetModal } from "./filter/[scope]/reset/modal"; +import { filterResetPost } from "./filter/[scope]/reset/post"; +import { filterTypePost } from "./filter/[scope]/type/post"; +import { homeGet } from "./get"; +import { modeGet } from "./mode/get"; +import { modePost } from "./mode/post"; +import { recipientsAutoRingPost } from "./recipients/[scope]/auto-ring/post"; +import { recipientsAutoRingUnsetPost } from "./recipients/[scope]/auto-ring/unset/post"; +import { recipientsClearModal } from "./recipients/[scope]/clear/modal"; +import { recipientsClearPost } from "./recipients/[scope]/clear/post"; +import { recipientsGet } from "./recipients/[scope]/get"; +import { recipientsMembersPost } from "./recipients/[scope]/members/post"; +import { ringDefaultPost } from "./ring/default/post"; +import { ringGet } from "./ring/get"; +import { ringUserPost } from "./ring/user/post"; +import { ringUsersPost } from "./ring/users/post"; +import { signupsGet } from "./signups/get"; +import { signupsMembersPost } from "./signups/members/post"; +import { signupsRoleGet } from "./signups/roles/[roleId]/get"; +import { signupsRolePost } from "./signups/roles/[roleId]/post"; +import { signupsRolesGet } from "./signups/roles/get"; +import { signupsRolesRemovePost } from "./signups/roles/remove/post"; + +// handlers live in files mirroring their route: the folder is the path (with +// [param] segments) and the file is the method export const registerRoutes = (router: RingRouter) => { - router.route("/", homeHandlers.panel); - router.route("/commands", homeHandlers.commands); + router.route("/", { get: homeGet }); + router.route("/commands", { get: commandsGet }); // scoped panels answer their bare path as the global scope, so their // scope-switch channel select can target "{/:channelId}" and fall back to // global when the selection is cleared - router.route(["/filter", "/filter/:scope"], filterHandlers.panel); - router.route("/filter/:scope/members", filterHandlers.members); - router.route("/filter/:scope/type", filterHandlers.type); - router.route("/filter/:scope/reset", filterHandlers.reset); - - router.route(["/recipients", "/recipients/:scope"], recipientsHandlers.panel); - router.route("/recipients/:scope/members", recipientsHandlers.members); - router.route("/recipients/:scope/clear", recipientsHandlers.clear); - router.route("/recipients/:scope/auto-ring", recipientsHandlers.autoRing); - router.route( - "/recipients/:scope/auto-ring/unset", - recipientsHandlers.autoRingUnset, - ); - - router.route("/mode", modeHandlers.panel); - - router.route("/signups", signupsHandlers.panel); - router.route("/signups/members", signupsHandlers.members); - router.route("/signups/roles", signupsHandlers.roles); - router.route("/signups/roles/remove", signupsHandlers.rolesRemove); + router.route(["/filter", "/filter/:scope"], { get: filterGet }); + router.route("/filter/:scope/members", { post: filterMembersPost }); + router.route("/filter/:scope/type", { post: filterTypePost }); + router.route("/filter/:scope/reset", { + modal: filterResetModal, + post: filterResetPost, + }); + + router.route(["/recipients", "/recipients/:scope"], { get: recipientsGet }); + router.route("/recipients/:scope/members", { post: recipientsMembersPost }); + router.route("/recipients/:scope/clear", { + modal: recipientsClearModal, + post: recipientsClearPost, + }); + router.route("/recipients/:scope/auto-ring", { + post: recipientsAutoRingPost, + }); + router.route("/recipients/:scope/auto-ring/unset", { + post: recipientsAutoRingUnsetPost, + }); + + router.route("/mode", { get: modeGet, post: modePost }); + + router.route("/signups", { get: signupsGet }); + router.route("/signups/members", { post: signupsMembersPost }); + router.route("/signups/roles", { get: signupsRolesGet }); + router.route("/signups/roles/remove", { post: signupsRolesRemovePost }); // registered after /signups/roles/remove so "remove" never binds :roleId - router.route("/signups/roles/:roleId", signupsHandlers.rolePage); + router.route("/signups/roles/:roleId", { + get: signupsRoleGet, + post: signupsRolePost, + }); - router.route("/ring", ringHandlers.panel); - router.route("/ring/users", ringHandlers.users); - router.route("/ring/user", ringHandlers.user); - router.route("/ring/default", ringHandlers.default); + router.route("/ring", { get: ringGet }); + router.route("/ring/users", { post: ringUsersPost }); + router.route("/ring/user", { post: ringUserPost }); + router.route("/ring/default", { post: ringDefaultPost }); - router.route("/delete-data", deleteDataHandlers.panel); - router.route("/delete-data/confirm", deleteDataHandlers.confirm); + router.route("/delete-data", { get: deleteDataGet, post: deleteDataPost }); + router.route("/delete-data/confirm", { modal: deleteDataConfirmModal }); }; diff --git a/src/routes/mode.ts b/src/routes/mode.ts deleted file mode 100644 index 51019bf..0000000 --- a/src/routes/mode.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { homeButton, row } from "@routes/lib/components"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { Handler, Handlers } from "@routes/types"; -import { RouteButtonBuilder } from "discord-embed-router"; -import { ButtonStyle, EmbedBuilder } from "discord.js"; - -import { DiscordUserMode, getUserMode, setUserMode } from "@db/users"; - -const COLOR = "#F5853F"; -const PATH = "/mode"; - -const MODES: { mode: DiscordUserMode; label: string; effect: string }[] = [ - { - mode: "normal", - label: "Normal", - effect: "Joining a voice channel rings all applicable users", - }, - { - mode: "stealth", - label: "Stealth", - effect: "Joining a voice channel rings nobody", - }, - { - mode: "auto", - label: "Auto", - effect: "Stealth while you are invisible on Discord, normal otherwise", - }, -]; - -const isMode = (value: string | null): value is DiscordUserMode => - MODES.some(({ mode }) => mode === value); - -const panelGet: Handler<"GET"> = async (router, interaction, state) => { - const current = await getUserMode(interaction.user.id); - - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("Your mode") - .setDescription( - withFlash( - state.queryParams, - `Modes decide what happens when you join a voice channel. Your current mode is **${current}**.`, - ), - ) - .addFields( - MODES.map(({ label, effect }) => ({ name: label, value: effect })), - ), - ], - components: [ - row( - ...MODES.map(({ mode, label }) => - new RouteButtonBuilder(router) - .setLabel(label) - .setStyle( - mode === current ? ButtonStyle.Primary : ButtonStyle.Secondary, - ) - .setDisabled(mode === current) - .setTo(PATH, { method: "POST", queryParams: { set: mode } }), - ), - homeButton(router), - ), - ], - }; -}; - -const panelPost: Handler<"POST"> = async (router, interaction, state) => { - const target = state.queryParams.get("set"); - if (!isMode(target)) - return flashRedirect(PATH, "Unknown mode, nothing changed", "warn"); - - const current = await getUserMode(interaction.user.id); - if (current === target) - return flashRedirect(PATH, `Your mode is already ${target}`, "warn"); - - await setUserMode(interaction.user.id, target); - const effect = MODES.find(({ mode }) => mode === target)?.effect ?? ""; - return flashRedirect(PATH, `Mode set to ${target}. ${effect}`, "success"); -}; - -export const modeHandlers = { - panel: { get: panelGet, post: panelPost } satisfies Handlers, -}; diff --git a/src/routes/mode/_shared.ts b/src/routes/mode/_shared.ts new file mode 100644 index 0000000..a659011 --- /dev/null +++ b/src/routes/mode/_shared.ts @@ -0,0 +1,28 @@ +import { DiscordUserMode } from "@db/users"; + +export const PATH = "/mode"; + +export const MODES: { + mode: DiscordUserMode; + label: string; + effect: string; +}[] = [ + { + mode: "normal", + label: "Normal", + effect: "Joining a voice channel rings all applicable users", + }, + { + mode: "stealth", + label: "Stealth", + effect: "Joining a voice channel rings nobody", + }, + { + mode: "auto", + label: "Auto", + effect: "Stealth while you are invisible on Discord, normal otherwise", + }, +]; + +export const isMode = (value: string | null): value is DiscordUserMode => + MODES.some(({ mode }) => mode === value); diff --git a/src/routes/mode/get.ts b/src/routes/mode/get.ts new file mode 100644 index 0000000..6be2ff5 --- /dev/null +++ b/src/routes/mode/get.ts @@ -0,0 +1,46 @@ +import { homeButton, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; +import { RouteButtonBuilder } from "discord-embed-router"; +import { ButtonStyle, EmbedBuilder } from "discord.js"; + +import { getUserMode } from "@db/users"; + +import { MODES, PATH } from "./_shared"; + +const COLOR = "#F5853F"; + +export const modeGet: Handler<"GET"> = async (router, interaction, state) => { + const current = await getUserMode(interaction.user.id); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Your mode") + .setDescription( + withFlash( + state.queryParams, + `Modes decide what happens when you join a voice channel. Your current mode is **${current}**.`, + ), + ) + .addFields( + MODES.map(({ label, effect }) => ({ name: label, value: effect })), + ), + ], + components: [ + row( + ...MODES.map(({ mode, label }) => + new RouteButtonBuilder(router) + .setLabel(label) + .setStyle( + mode === current ? ButtonStyle.Primary : ButtonStyle.Secondary, + ) + .setDisabled(mode === current) + .setTo(PATH, { method: "POST", queryParams: { set: mode } }), + ), + homeButton(router), + ), + ], + }; +}; diff --git a/src/routes/mode.test.ts b/src/routes/mode/mode.test.ts similarity index 83% rename from src/routes/mode.test.ts rename to src/routes/mode/mode.test.ts index c94d055..c85f2a4 100644 --- a/src/routes/mode.test.ts +++ b/src/routes/mode/mode.test.ts @@ -1,9 +1,10 @@ -import { modeHandlers } from "@routes/mode"; import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { getUserMode, setUserMode } from "@db/users"; +import { modePost } from "./post"; + vi.mock("@db/users", () => ({ getUserMode: vi.fn(), setUserMode: vi.fn(), @@ -11,14 +12,13 @@ vi.mock("@db/users", () => ({ const interaction = { user: { id: "caller" } } as unknown as Interaction; -type ModePost = NonNullable; const state = (query: string) => ({ params: {}, path: "/mode", queryParams: new URLSearchParams(query), timestamp: 0, - }) as unknown as Parameters[2]; + }) as unknown as Parameters[2]; beforeEach(() => { vi.clearAllMocks(); @@ -27,7 +27,7 @@ beforeEach(() => { test("setting a mode persists it and confirms what it does", async () => { vi.mocked(getUserMode).mockResolvedValue("normal"); - const result = await modeHandlers.panel.post!( + const result = await modePost( undefined as never, interaction, state("set=stealth"), @@ -43,7 +43,7 @@ test("setting a mode persists it and confirms what it does", async () => { test("an unknown mode value writes nothing", async () => { vi.mocked(getUserMode).mockResolvedValue("normal"); - const result = await modeHandlers.panel.post!( + const result = await modePost( undefined as never, interaction, state("set=loud"), diff --git a/src/routes/mode/post.ts b/src/routes/mode/post.ts new file mode 100644 index 0000000..f39b291 --- /dev/null +++ b/src/routes/mode/post.ts @@ -0,0 +1,20 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { getUserMode, setUserMode } from "@db/users"; + +import { isMode, MODES, PATH } from "./_shared"; + +export const modePost: Handler<"POST"> = async (router, interaction, state) => { + const target = state.queryParams.get("set"); + if (!isMode(target)) + return flashRedirect(PATH, "Unknown mode, nothing changed", "warn"); + + const current = await getUserMode(interaction.user.id); + if (current === target) + return flashRedirect(PATH, `Your mode is already ${target}`, "warn"); + + await setUserMode(interaction.user.id, target); + const effect = MODES.find(({ mode }) => mode === target)?.effect ?? ""; + return flashRedirect(PATH, `Mode set to ${target}. ${effect}`, "success"); +}; diff --git a/src/routes/recipients.ts b/src/routes/recipients.ts deleted file mode 100644 index 78a1642..0000000 --- a/src/routes/recipients.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { homeButton, paginationRows, row } from "@routes/lib/components"; -import { confirmed, confirmModal } from "@routes/lib/confirm"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; -import { channelIdOf, scopeOf } from "@routes/lib/scope"; -import { Handler, Handlers } from "@routes/types"; -import { - RouteButtonBuilder, - RouteChannelSelectMenuBuilder, - RouteUserSelectMenuBuilder, -} from "discord-embed-router"; -import { - ActionRowBuilder, - ButtonStyle, - ChannelType, - EmbedBuilder, -} from "discord.js"; - -import { getAutoRingSetting, setAutoRing, unsetAutoRing } from "@db/auto-ring"; -import { - addDefaultRingee, - clearDefaultRingees, - getDefaultRingees, - removeDefaultRingee, -} from "@db/default-ringees"; -import { joinWithAnd, mentionUser } from "@main/ring"; - -const COLOR = "#747ac5"; - -const panelPath = (scope: string) => `/recipients/${scope}`; - -// "for <#id>" / "globally" -const scopeSuffix = (scope: string) => - scope === "global" ? "globally" : `for <#${scope}>`; - -// the channel override when in channel scope, the global setting, and the -// value that actually applies for the scope -const autoRingStatus = async (userId: string, channelId: string | null) => { - const [override, global] = await Promise.all([ - channelId === null - ? Promise.resolve(undefined) - : getAutoRingSetting(userId, channelId), - getAutoRingSetting(userId, null), - ]); - return { - override, - global: global ?? false, - effective: override ?? global ?? false, - }; -}; - -const panelGet: Handler<"GET"> = async (router, interaction, state) => { - const scope = scopeOf(state.params); - const channelId = channelIdOf(scope); - const userId = interaction.user.id; - const [ringees, autoRing] = await Promise.all([ - getDefaultRingees(userId, channelId), - autoRingStatus(userId, channelId), - ]); - const sorted = [...ringees].sort(); - const { pageItems, page, pageCount } = paginate( - sorted, - state.queryParams.get("page"), - ); - - const autoRingLine = - channelId === null - ? `Auto-ring: **${autoRing.effective ? "enabled" : "disabled"}**` - : autoRing.override !== undefined - ? `Auto-ring: **${autoRing.override ? "enabled" : "disabled"}** for <#${channelId}> (overrides your global setting: ${autoRing.global ? "enabled" : "disabled"})` - : `Auto-ring: **${autoRing.effective ? "enabled" : "disabled"}** (from your global setting)`; - - const memberList = - pageItems.length > 0 ? pageItems.map(mentionUser).join(" ") : "None"; - const description = withFlash( - state.queryParams, - `These people get rung when you use Ring defaults${channelId ? ` in <#${channelId}>` : ""}, or on every voice channel join if auto-ring is enabled.\n\n` + - `${autoRingLine}\n\n` + - `**Recipients${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:** ${memberList}`, - ); - - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle( - scope === "global" - ? "Your default ring recipients" - : "Channel ring recipients", - ) - .setDescription(description), - ], - components: [ - new ActionRowBuilder() - .addComponents( - new RouteChannelSelectMenuBuilder(router) - .setChannelTypes(ChannelType.GuildVoice) - .setMinValues(0) - .setMaxValues(1) - .setPlaceholder( - channelId - ? "Viewing channel recipients (clear for global)" - : "View a voice channel's recipients", - ) - .setDefaultChannels(...(channelId ? [channelId] : [])) - .setPattern("/recipients{/:channelId}"), - ) - .toJSON(), - new ActionRowBuilder() - .addComponents( - new RouteUserSelectMenuBuilder(router) - .setMinValues(0) - .setMaxValues(PAGE_SIZE) - .setPlaceholder( - "Edit recipients: select to add, deselect to remove", - ) - .setDefaultUsers(...pageItems) - .setPattern(`${panelPath(scope)}/members`, { - method: "POST", - queryParams: { page: String(page) }, - }), - ) - .toJSON(), - row( - new RouteButtonBuilder(router) - .setLabel("Reset") - .setStyle(ButtonStyle.Danger) - .setTo(`${panelPath(scope)}/clear`, { method: "MODAL" }), - new RouteButtonBuilder(router) - .setLabel( - autoRing.effective ? "Disable auto-ring" : "Enable auto-ring", - ) - .setStyle( - autoRing.effective ? ButtonStyle.Secondary : ButtonStyle.Success, - ) - .setTo(`${panelPath(scope)}/auto-ring`, { - method: "POST", - queryParams: { enable: autoRing.effective ? "0" : "1" }, - }), - ...(channelId !== null && autoRing.override !== undefined - ? [ - new RouteButtonBuilder(router) - .setLabel("Remove override") - .setStyle(ButtonStyle.Secondary) - .setTo(`${panelPath(scope)}/auto-ring/unset`, { - method: "POST", - }), - ] - : []), - homeButton(router), - ), - ...paginationRows(router, panelPath(scope), { page, pageCount }), - ], - }; -}; - -const membersPost: Handler<"POST"> = async (router, interaction, state) => { - const scope = scopeOf(state.params); - const channelId = channelIdOf(scope); - const userId = interaction.user.id; - const panel = panelPath(scope); - - const ringees = [...(await getDefaultRingees(userId, channelId))].sort(); - const { pageItems } = paginate(ringees, state.queryParams.get("page")); - const { added, removed } = diffSelection({ - allItems: ringees, - pageItems, - submitted: state.values ?? pageItems, - }); - - await Promise.all([ - ...added.map((id) => addDefaultRingee(userId, channelId, id)), - ...removed.map((id) => removeDefaultRingee(userId, channelId, id)), - ]); - - const parts = [ - ...(added.length > 0 - ? [`Added ${joinWithAnd(added.map(mentionUser))}`] - : []), - ...(removed.length > 0 - ? [`Removed ${joinWithAnd(removed.map(mentionUser))}`] - : []), - ]; - const changed = parts.length > 0; - return flashRedirect( - panel, - changed - ? `${parts.join(". ")} (default ring recipients ${scopeSuffix(scope)})` - : `No changes to your default ring recipients ${scopeSuffix(scope)}`, - changed ? "success" : "warn", - { page: state.queryParams.get("page") ?? "0" }, - ); -}; - -const clearConfirm: Handler<"MODAL"> = (router, interaction, state) => - confirmModal(router, { - to: `${panelPath(scopeOf(state.params))}/clear`, - title: "Reset ring recipients", - word: "RESET", - }); - -const clearPost: Handler<"POST"> = async (router, interaction, state) => { - const scope = scopeOf(state.params); - if (!confirmed(state.fields, "RESET")) - return flashRedirect( - panelPath(scope), - "Confirmation text did not match, your recipients were not cleared", - "warn", - ); - const cleared = await clearDefaultRingees( - interaction.user.id, - channelIdOf(scope), - ); - return cleared - ? flashRedirect( - panelPath(scope), - `Cleared your default ring recipients ${scopeSuffix(scope)}`, - "success", - ) - : flashRedirect( - panelPath(scope), - `You already have no default ring recipients ${scopeSuffix(scope)}`, - "warn", - ); -}; - -const autoRingPost: Handler<"POST"> = async (router, interaction, state) => { - const scope = scopeOf(state.params); - const channelId = channelIdOf(scope); - const enable = state.queryParams.get("enable") === "1"; - const panel = panelPath(scope); - - const changed = await setAutoRing(interaction.user.id, channelId, enable); - if (!changed) - return flashRedirect( - panel, - `Auto-ring is already ${enable ? "enabled" : "disabled"} ${scopeSuffix(scope)}`, - "warn", - ); - return enable - ? flashRedirect( - panel, - `Auto-ring is now enabled ${scopeSuffix(scope)}. WARNING: joining ${channelId ? `<#${channelId}>` : "a voice channel"} now rings all of your default ring recipients, even in stealth mode`, - "warn", - ) - : flashRedirect( - panel, - `Auto-ring is now disabled ${scopeSuffix(scope)}`, - "success", - ); -}; - -const autoRingUnsetPost: Handler<"POST"> = async ( - router, - interaction, - state, -) => { - const scope = scopeOf(state.params); - const channelId = channelIdOf(scope); - const panel = panelPath(scope); - if (channelId === null) - return flashRedirect( - panel, - "Only channel scopes have an auto-ring override to remove", - "warn", - ); - - const existed = await unsetAutoRing(interaction.user.id, channelId); - return existed - ? flashRedirect( - panel, - `Removed the auto-ring override for <#${channelId}>; your global setting applies again`, - "success", - ) - : flashRedirect( - panel, - `You have no auto-ring override for <#${channelId}>`, - "warn", - ); -}; - -export const recipientsHandlers = { - panel: { get: panelGet } satisfies Handlers, - members: { post: membersPost } satisfies Handlers, - clear: { modal: clearConfirm, post: clearPost } satisfies Handlers, - autoRing: { post: autoRingPost } satisfies Handlers, - autoRingUnset: { post: autoRingUnsetPost } satisfies Handlers, -}; diff --git a/src/routes/recipients/[scope]/auto-ring/post.ts b/src/routes/recipients/[scope]/auto-ring/post.ts new file mode 100644 index 0000000..b3b6ac2 --- /dev/null +++ b/src/routes/recipients/[scope]/auto-ring/post.ts @@ -0,0 +1,37 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { channelIdOf, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { setAutoRing } from "@db/auto-ring"; + +import { panelPath, scopeSuffix } from "../../_shared"; + +export const recipientsAutoRingPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const enable = state.queryParams.get("enable") === "1"; + const panel = panelPath(scope); + + const changed = await setAutoRing(interaction.user.id, channelId, enable); + if (!changed) + return flashRedirect( + panel, + `Auto-ring is already ${enable ? "enabled" : "disabled"} ${scopeSuffix(scope)}`, + "warn", + ); + return enable + ? flashRedirect( + panel, + `Auto-ring is now enabled ${scopeSuffix(scope)}. WARNING: joining ${channelId ? `<#${channelId}>` : "a voice channel"} now rings all of your default ring recipients, even in stealth mode`, + "warn", + ) + : flashRedirect( + panel, + `Auto-ring is now disabled ${scopeSuffix(scope)}`, + "success", + ); +}; diff --git a/src/routes/recipients/[scope]/auto-ring/unset/post.ts b/src/routes/recipients/[scope]/auto-ring/unset/post.ts new file mode 100644 index 0000000..d2f1f78 --- /dev/null +++ b/src/routes/recipients/[scope]/auto-ring/unset/post.ts @@ -0,0 +1,36 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { channelIdOf, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { unsetAutoRing } from "@db/auto-ring"; + +import { panelPath } from "../../../_shared"; + +export const recipientsAutoRingUnsetPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const panel = panelPath(scope); + if (channelId === null) + return flashRedirect( + panel, + "Only channel scopes have an auto-ring override to remove", + "warn", + ); + + const existed = await unsetAutoRing(interaction.user.id, channelId); + return existed + ? flashRedirect( + panel, + `Removed the auto-ring override for <#${channelId}>; your global setting applies again`, + "success", + ) + : flashRedirect( + panel, + `You have no auto-ring override for <#${channelId}>`, + "warn", + ); +}; diff --git a/src/routes/recipients/[scope]/clear/modal.ts b/src/routes/recipients/[scope]/clear/modal.ts new file mode 100644 index 0000000..8f89983 --- /dev/null +++ b/src/routes/recipients/[scope]/clear/modal.ts @@ -0,0 +1,16 @@ +import { confirmModal } from "@routes/lib/confirm"; +import { scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { panelPath } from "../../_shared"; + +export const recipientsClearModal: Handler<"MODAL"> = ( + router, + interaction, + state, +) => + confirmModal(router, { + to: `${panelPath(scopeOf(state.params))}/clear`, + title: "Reset ring recipients", + word: "RESET", + }); diff --git a/src/routes/recipients/[scope]/clear/post.ts b/src/routes/recipients/[scope]/clear/post.ts new file mode 100644 index 0000000..2e20a4e --- /dev/null +++ b/src/routes/recipients/[scope]/clear/post.ts @@ -0,0 +1,37 @@ +import { confirmed } from "@routes/lib/confirm"; +import { flashRedirect } from "@routes/lib/flash"; +import { channelIdOf, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { clearDefaultRingees } from "@db/default-ringees"; + +import { panelPath, scopeSuffix } from "../../_shared"; + +export const recipientsClearPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + if (!confirmed(state.fields, "RESET")) + return flashRedirect( + panelPath(scope), + "Confirmation text did not match, your recipients were not cleared", + "warn", + ); + const cleared = await clearDefaultRingees( + interaction.user.id, + channelIdOf(scope), + ); + return cleared + ? flashRedirect( + panelPath(scope), + `Cleared your default ring recipients ${scopeSuffix(scope)}`, + "success", + ) + : flashRedirect( + panelPath(scope), + `You already have no default ring recipients ${scopeSuffix(scope)}`, + "warn", + ); +}; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts new file mode 100644 index 0000000..998f70d --- /dev/null +++ b/src/routes/recipients/[scope]/get.ts @@ -0,0 +1,149 @@ +import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { channelIdOf, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, + RouteUserSelectMenuBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, +} from "discord.js"; + +import { getAutoRingSetting } from "@db/auto-ring"; +import { getDefaultRingees } from "@db/default-ringees"; +import { mentionUser } from "@main/ring"; + +import { panelPath } from "../_shared"; + +const COLOR = "#747ac5"; + +// the channel override when in channel scope, the global setting, and the +// value that actually applies for the scope +const autoRingStatus = async (userId: string, channelId: string | null) => { + const [override, global] = await Promise.all([ + channelId === null + ? Promise.resolve(undefined) + : getAutoRingSetting(userId, channelId), + getAutoRingSetting(userId, null), + ]); + return { + override, + global: global ?? false, + effective: override ?? global ?? false, + }; +}; + +export const recipientsGet: Handler<"GET"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const userId = interaction.user.id; + const [ringees, autoRing] = await Promise.all([ + getDefaultRingees(userId, channelId), + autoRingStatus(userId, channelId), + ]); + const sorted = [...ringees].sort(); + const { pageItems, page, pageCount } = paginate( + sorted, + state.queryParams.get("page"), + ); + + const autoRingLine = + channelId === null + ? `Auto-ring: **${autoRing.effective ? "enabled" : "disabled"}**` + : autoRing.override !== undefined + ? `Auto-ring: **${autoRing.override ? "enabled" : "disabled"}** for <#${channelId}> (overrides your global setting: ${autoRing.global ? "enabled" : "disabled"})` + : `Auto-ring: **${autoRing.effective ? "enabled" : "disabled"}** (from your global setting)`; + + const memberList = + pageItems.length > 0 ? pageItems.map(mentionUser).join(" ") : "None"; + const description = withFlash( + state.queryParams, + `These people get rung when you use Ring defaults${channelId ? ` in <#${channelId}>` : ""}, or on every voice channel join if auto-ring is enabled.\n\n` + + `${autoRingLine}\n\n` + + `**Recipients${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:** ${memberList}`, + ); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle( + scope === "global" + ? "Your default ring recipients" + : "Channel ring recipients", + ) + .setDescription(description), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder( + channelId + ? "Viewing channel recipients (clear for global)" + : "View a voice channel's recipients", + ) + .setDefaultChannels(...(channelId ? [channelId] : [])) + .setPattern("/recipients{/:channelId}"), + ) + .toJSON(), + new ActionRowBuilder() + .addComponents( + new RouteUserSelectMenuBuilder(router) + .setMinValues(0) + .setMaxValues(PAGE_SIZE) + .setPlaceholder( + "Edit recipients: select to add, deselect to remove", + ) + .setDefaultUsers(...pageItems) + .setPattern(`${panelPath(scope)}/members`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON(), + row( + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Danger) + .setTo(`${panelPath(scope)}/clear`, { method: "MODAL" }), + new RouteButtonBuilder(router) + .setLabel( + autoRing.effective ? "Disable auto-ring" : "Enable auto-ring", + ) + .setStyle( + autoRing.effective ? ButtonStyle.Secondary : ButtonStyle.Success, + ) + .setTo(`${panelPath(scope)}/auto-ring`, { + method: "POST", + queryParams: { enable: autoRing.effective ? "0" : "1" }, + }), + ...(channelId !== null && autoRing.override !== undefined + ? [ + new RouteButtonBuilder(router) + .setLabel("Remove override") + .setStyle(ButtonStyle.Secondary) + .setTo(`${panelPath(scope)}/auto-ring/unset`, { + method: "POST", + }), + ] + : []), + homeButton(router), + ), + ...paginationRows(router, panelPath(scope), { page, pageCount }), + ], + }; +}; diff --git a/src/routes/recipients/[scope]/members/post.ts b/src/routes/recipients/[scope]/members/post.ts new file mode 100644 index 0000000..a879e48 --- /dev/null +++ b/src/routes/recipients/[scope]/members/post.ts @@ -0,0 +1,55 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { diffSelection, paginate } from "@routes/lib/paging"; +import { channelIdOf, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; + +import { + addDefaultRingee, + getDefaultRingees, + removeDefaultRingee, +} from "@db/default-ringees"; +import { joinWithAnd, mentionUser } from "@main/ring"; + +import { panelPath, scopeSuffix } from "../../_shared"; + +export const recipientsMembersPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const scope = scopeOf(state.params); + const channelId = channelIdOf(scope); + const userId = interaction.user.id; + const panel = panelPath(scope); + + const ringees = [...(await getDefaultRingees(userId, channelId))].sort(); + const { pageItems } = paginate(ringees, state.queryParams.get("page")); + const { added, removed } = diffSelection({ + allItems: ringees, + pageItems, + submitted: state.values ?? pageItems, + }); + + await Promise.all([ + ...added.map((id) => addDefaultRingee(userId, channelId, id)), + ...removed.map((id) => removeDefaultRingee(userId, channelId, id)), + ]); + + const parts = [ + ...(added.length > 0 + ? [`Added ${joinWithAnd(added.map(mentionUser))}`] + : []), + ...(removed.length > 0 + ? [`Removed ${joinWithAnd(removed.map(mentionUser))}`] + : []), + ]; + const changed = parts.length > 0; + return flashRedirect( + panel, + changed + ? `${parts.join(". ")} (default ring recipients ${scopeSuffix(scope)})` + : `No changes to your default ring recipients ${scopeSuffix(scope)}`, + changed ? "success" : "warn", + { page: state.queryParams.get("page") ?? "0" }, + ); +}; diff --git a/src/routes/recipients/_shared.ts b/src/routes/recipients/_shared.ts new file mode 100644 index 0000000..6f260e2 --- /dev/null +++ b/src/routes/recipients/_shared.ts @@ -0,0 +1,5 @@ +export const panelPath = (scope: string) => `/recipients/${scope}`; + +// "for <#id>" / "globally" +export const scopeSuffix = (scope: string) => + scope === "global" ? "globally" : `for <#${scope}>`; diff --git a/src/routes/recipients.test.ts b/src/routes/recipients/recipients.test.ts similarity index 85% rename from src/routes/recipients.test.ts rename to src/routes/recipients/recipients.test.ts index 96778ed..0a10e71 100644 --- a/src/routes/recipients.test.ts +++ b/src/routes/recipients/recipients.test.ts @@ -1,10 +1,13 @@ -import { recipientsHandlers } from "@routes/recipients"; import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { setAutoRing, unsetAutoRing } from "@db/auto-ring"; import { clearDefaultRingees } from "@db/default-ringees"; +import { recipientsAutoRingPost } from "./[scope]/auto-ring/post"; +import { recipientsAutoRingUnsetPost } from "./[scope]/auto-ring/unset/post"; +import { recipientsClearPost } from "./[scope]/clear/post"; + vi.mock("@db/auto-ring", () => ({ getAutoRingSetting: vi.fn(), setAutoRing: vi.fn(), @@ -19,28 +22,26 @@ vi.mock("@db/default-ringees", () => ({ const interaction = { user: { id: "caller" } } as unknown as Interaction; -type AutoRingPost = NonNullable; const autoRingState = (scope: string, query: string) => ({ params: { scope }, path: `/recipients/${scope}/auto-ring`, queryParams: new URLSearchParams(query), timestamp: 0, - }) as unknown as Parameters[2]; - -beforeEach(() => { - vi.clearAllMocks(); -}); + }) as unknown as Parameters[2]; -type ClearPost = NonNullable; const clearPost = (confirmation: string) => - recipientsHandlers.clear.post!(undefined as never, interaction, { + recipientsClearPost(undefined as never, interaction, { params: { scope: "global" }, path: "/recipients/global/clear", queryParams: new URLSearchParams(), timestamp: 0, fields: { getTextInputValue: () => confirmation }, - } as unknown as Parameters[2]); + } as unknown as Parameters[2]); + +beforeEach(() => { + vi.clearAllMocks(); +}); test("a recipients reset with matching confirmation text clears the list", async () => { vi.mocked(clearDefaultRingees).mockResolvedValue(true); @@ -68,7 +69,7 @@ test("a recipients reset without matching confirmation text mutates nothing", as test("enabling auto-ring warns that joins ring default recipients even in stealth", async () => { vi.mocked(setAutoRing).mockResolvedValue(true); - const result = await recipientsHandlers.autoRing.post!( + const result = await recipientsAutoRingPost( undefined as never, interaction, autoRingState("123", "enable=1"), @@ -85,7 +86,7 @@ test("enabling auto-ring warns that joins ring default recipients even in stealt test("toggling auto-ring to its current value reports no change", async () => { vi.mocked(setAutoRing).mockResolvedValue(false); - const result = await recipientsHandlers.autoRing.post!( + const result = await recipientsAutoRingPost( undefined as never, interaction, autoRingState("global", "enable=0"), @@ -100,7 +101,7 @@ test("toggling auto-ring to its current value reports no change", async () => { test("removing a missing auto-ring override reports there was none", async () => { vi.mocked(unsetAutoRing).mockResolvedValue(false); - const result = await recipientsHandlers.autoRingUnset.post!( + const result = await recipientsAutoRingUnsetPost( undefined as never, interaction, autoRingState("123", ""), diff --git a/src/routes/ring.ts b/src/routes/ring.ts deleted file mode 100644 index 6e9ecb8..0000000 --- a/src/routes/ring.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { homeButton, row } from "@routes/lib/components"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { commandMention } from "@routes/lib/mentions"; -import { PAGE_SIZE } from "@routes/lib/paging"; -import { Handler, Handlers } from "@routes/types"; -import { - RouteButtonBuilder, - RouteUserSelectMenuBuilder, -} from "discord-embed-router"; -import { - ActionRowBuilder, - ButtonStyle, - EmbedBuilder, - Interaction, - VoiceBasedChannel, -} from "discord.js"; - -import { getAllDefaultRingees } from "@db/default-ringees"; -import { - getErrorMessage, - joinWithAnd, - mentionUser, - ring, - ringDefaultUsers, - UserRingResult, -} from "@main/ring"; - -const COLOR = "#c58a74"; -const PANEL = "/ring"; -const NOT_IN_VC = - "RingVC needs to know which voice channel to ring people into. Join one, then run /ring again."; - -// panels outlive the voice state that was true at render time, so every -// mutation re-checks it at click time -const voiceChannelOf = (interaction: Interaction): VoiceBasedChannel | null => - interaction.member && "voice" in interaction.member - ? interaction.member.voice.channel - : null; - -const panelGet: Handler<"GET"> = async (router, interaction, state) => { - const channel = voiceChannelOf(interaction); - if (!channel) - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("Ring") - .setDescription(`⚠️ ${NOT_IN_VC}`), - ], - components: [row(homeButton(router))], - }; - - const defaults = await getAllDefaultRingees(interaction.user.id, channel.id); - const description = withFlash( - state.queryParams, - `Ringing people into <#${channel.id}>.\n\n` + - (defaults.length > 0 - ? `Your default recipients here: ${joinWithAnd(defaults.map(mentionUser))}` - : "You have no default recipients here."), - ); - - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("Ring") - .setDescription(description), - ], - components: [ - new ActionRowBuilder() - .addComponents( - new RouteUserSelectMenuBuilder(router) - .setMinValues(1) - .setMaxValues(PAGE_SIZE) - .setPlaceholder("Select up to 25 people to ring") - .setPattern(`${PANEL}/users`, { method: "POST" }), - ) - .toJSON(), - row( - new RouteButtonBuilder(router) - .setLabel("Ring defaults") - .setStyle(ButtonStyle.Success) - .setTo(`${PANEL}/default`, { method: "POST" }), - homeButton(router), - ), - ], - }; -}; - -const ringResultsFlash = (results: UserRingResult[]) => { - const ringed = results - .filter((result) => result.status === "fulfilled") - .map((result) => mentionUser(result.userId)); - const lines = [ - ...(ringed.length > 0 ? [`Ringed ${joinWithAnd(ringed)}`] : []), - ...results - .filter((result) => result.status === "rejected") - .map( - (result) => - `Can't ring ${mentionUser(result.userId)} because ${result.error.message}`, - ), - ]; - return { - flash: lines.join("\n"), - level: ringed.length > 0 ? ("success" as const) : ("warn" as const), - }; -}; - -const ringUserIds = async (interaction: Interaction, userIds: string[]) => { - const channel = voiceChannelOf(interaction); - if (!channel) return flashRedirect(PANEL, NOT_IN_VC, "warn"); - if (userIds.length === 0) - return flashRedirect(PANEL, "Nobody was selected to ring", "warn"); - - try { - const results = await ring( - channel, - interaction.user.id, - "wants you to join", - userIds, - ); - const { flash, level } = ringResultsFlash(results); - return flashRedirect(PANEL, flash, level); - } catch (err) { - return flashRedirect( - PANEL, - `Can't ring because ${getErrorMessage(err)}`, - "warn", - ); - } -}; - -const usersPost: Handler<"POST"> = async (router, interaction, state) => - ringUserIds(interaction, state.values ?? []); - -// the /ring user:@x quick path -const userPost: Handler<"POST"> = async (router, interaction, state) => { - const userId = state.queryParams.get("id"); - return ringUserIds(interaction, userId ? [userId] : []); -}; - -const defaultPost: Handler<"POST"> = async (router, interaction, state) => { - const channel = voiceChannelOf(interaction); - if (!channel) return flashRedirect(PANEL, NOT_IN_VC, "warn"); - - try { - const results = await ringDefaultUsers( - channel, - interaction.user.id, - "wants you to join", - ); - const { flash, level } = ringResultsFlash(results); - return flashRedirect(PANEL, flash, level); - } catch (err) { - const message = getErrorMessage(err); - return message === "no default users to ring" - ? flashRedirect( - PANEL, - `You have no default ring recipients. Use the Ring recipients panel on the home page or ${commandMention(state.globals, "default_ring_recipients")} to add some`, - "warn", - ) - : flashRedirect( - PANEL, - `Can't ring your default recipients because ${message}`, - "warn", - ); - } -}; - -export const ringHandlers = { - panel: { get: panelGet } satisfies Handlers, - users: { post: usersPost } satisfies Handlers, - user: { post: userPost } satisfies Handlers, - default: { post: defaultPost } satisfies Handlers, -}; diff --git a/src/routes/ring/_shared.ts b/src/routes/ring/_shared.ts new file mode 100644 index 0000000..93fefdd --- /dev/null +++ b/src/routes/ring/_shared.ts @@ -0,0 +1,69 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { Interaction, VoiceBasedChannel } from "discord.js"; + +import { + getErrorMessage, + joinWithAnd, + mentionUser, + ring, + UserRingResult, +} from "@main/ring"; + +export const PANEL = "/ring"; +export const NOT_IN_VC = + "RingVC needs to know which voice channel to ring people into. Join one, then run /ring again."; + +// panels outlive the voice state that was true at render time, so every +// mutation re-checks it at click time +export const voiceChannelOf = ( + interaction: Interaction, +): VoiceBasedChannel | null => + interaction.member && "voice" in interaction.member + ? interaction.member.voice.channel + : null; + +export const ringResultsFlash = (results: UserRingResult[]) => { + const ringed = results + .filter((result) => result.status === "fulfilled") + .map((result) => mentionUser(result.userId)); + const lines = [ + ...(ringed.length > 0 ? [`Ringed ${joinWithAnd(ringed)}`] : []), + ...results + .filter((result) => result.status === "rejected") + .map( + (result) => + `Can't ring ${mentionUser(result.userId)} because ${result.error.message}`, + ), + ]; + return { + flash: lines.join("\n"), + level: ringed.length > 0 ? ("success" as const) : ("warn" as const), + }; +}; + +export const ringUserIds = async ( + interaction: Interaction, + userIds: string[], +) => { + const channel = voiceChannelOf(interaction); + if (!channel) return flashRedirect(PANEL, NOT_IN_VC, "warn"); + if (userIds.length === 0) + return flashRedirect(PANEL, "Nobody was selected to ring", "warn"); + + try { + const results = await ring( + channel, + interaction.user.id, + "wants you to join", + userIds, + ); + const { flash, level } = ringResultsFlash(results); + return flashRedirect(PANEL, flash, level); + } catch (err) { + return flashRedirect( + PANEL, + `Can't ring because ${getErrorMessage(err)}`, + "warn", + ); + } +}; diff --git a/src/routes/ring/default/post.ts b/src/routes/ring/default/post.ts new file mode 100644 index 0000000..6575a88 --- /dev/null +++ b/src/routes/ring/default/post.ts @@ -0,0 +1,39 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; +import { Handler } from "@routes/types"; + +import { getErrorMessage, ringDefaultUsers } from "@main/ring"; + +import { NOT_IN_VC, PANEL, ringResultsFlash, voiceChannelOf } from "../_shared"; + +export const ringDefaultPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const channel = voiceChannelOf(interaction); + if (!channel) return flashRedirect(PANEL, NOT_IN_VC, "warn"); + + try { + const results = await ringDefaultUsers( + channel, + interaction.user.id, + "wants you to join", + ); + const { flash, level } = ringResultsFlash(results); + return flashRedirect(PANEL, flash, level); + } catch (err) { + const message = getErrorMessage(err); + return message === "no default users to ring" + ? flashRedirect( + PANEL, + `You have no default ring recipients. Use the Ring recipients panel on the home page or ${commandMention(state.globals, "default_ring_recipients")} to add some`, + "warn", + ) + : flashRedirect( + PANEL, + `Can't ring your default recipients because ${message}`, + "warn", + ); + } +}; diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts new file mode 100644 index 0000000..8ee6bab --- /dev/null +++ b/src/routes/ring/get.ts @@ -0,0 +1,66 @@ +import { homeButton, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { PAGE_SIZE } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; +import { + RouteButtonBuilder, + RouteUserSelectMenuBuilder, +} from "discord-embed-router"; +import { ActionRowBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; + +import { getAllDefaultRingees } from "@db/default-ringees"; +import { joinWithAnd, mentionUser } from "@main/ring"; + +import { NOT_IN_VC, PANEL, voiceChannelOf } from "./_shared"; + +const COLOR = "#c58a74"; + +export const ringGet: Handler<"GET"> = async (router, interaction, state) => { + const channel = voiceChannelOf(interaction); + if (!channel) + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Ring") + .setDescription(`⚠️ ${NOT_IN_VC}`), + ], + components: [row(homeButton(router))], + }; + + const defaults = await getAllDefaultRingees(interaction.user.id, channel.id); + const description = withFlash( + state.queryParams, + `Ringing people into <#${channel.id}>.\n\n` + + (defaults.length > 0 + ? `Your default recipients here: ${joinWithAnd(defaults.map(mentionUser))}` + : "You have no default recipients here."), + ); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Ring") + .setDescription(description), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new RouteUserSelectMenuBuilder(router) + .setMinValues(1) + .setMaxValues(PAGE_SIZE) + .setPlaceholder("Select up to 25 people to ring") + .setPattern(`${PANEL}/users`, { method: "POST" }), + ) + .toJSON(), + row( + new RouteButtonBuilder(router) + .setLabel("Ring defaults") + .setStyle(ButtonStyle.Success) + .setTo(`${PANEL}/default`, { method: "POST" }), + homeButton(router), + ), + ], + }; +}; diff --git a/src/routes/ring.test.ts b/src/routes/ring/ring.test.ts similarity index 88% rename from src/routes/ring.test.ts rename to src/routes/ring/ring.test.ts index 5908ac5..b4a7a4a 100644 --- a/src/routes/ring.test.ts +++ b/src/routes/ring/ring.test.ts @@ -1,9 +1,10 @@ -import { ringHandlers } from "@routes/ring"; import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { ring } from "@main/ring"; +import { ringUsersPost } from "./users/post"; + vi.mock("@main/ring", async (importOriginal) => ({ ...(await importOriginal()), ring: vi.fn(), @@ -20,7 +21,6 @@ const makeInteraction = (inVoice: boolean) => member: { voice: { channel: inVoice ? voiceChannel : null } }, }) as unknown as Interaction; -type UsersPost = NonNullable; const state = (query: string, values?: string[]) => ({ params: {}, @@ -29,14 +29,14 @@ const state = (query: string, values?: string[]) => timestamp: 0, globals: { commandIds: new Map() }, values, - }) as unknown as Parameters[2]; + }) as unknown as Parameters[2]; beforeEach(() => { vi.clearAllMocks(); }); test("ringing while not in a voice channel flashes the join hint and rings nobody", async () => { - const result = await ringHandlers.users.post!( + const result = await ringUsersPost( undefined as never, makeInteraction(false), state("", ["9"]), @@ -56,7 +56,7 @@ test("a selection rings the submitted users and reports per-user outcomes", asyn { userId: "8", status: "rejected", error: new Error("you blocked <@8>") }, ]); - const result = await ringHandlers.users.post!( + const result = await ringUsersPost( undefined as never, makeInteraction(true), state("", ["9", "8"]), diff --git a/src/routes/ring/user/post.ts b/src/routes/ring/user/post.ts new file mode 100644 index 0000000..af3ea9e --- /dev/null +++ b/src/routes/ring/user/post.ts @@ -0,0 +1,13 @@ +import { Handler } from "@routes/types"; + +import { ringUserIds } from "../_shared"; + +// the /ring user:@x quick path +export const ringUserPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const userId = state.queryParams.get("id"); + return ringUserIds(interaction, userId ? [userId] : []); +}; diff --git a/src/routes/ring/users/post.ts b/src/routes/ring/users/post.ts new file mode 100644 index 0000000..69dbc31 --- /dev/null +++ b/src/routes/ring/users/post.ts @@ -0,0 +1,9 @@ +import { Handler } from "@routes/types"; + +import { ringUserIds } from "../_shared"; + +export const ringUsersPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => ringUserIds(interaction, state.values ?? []); diff --git a/src/routes/signups.ts b/src/routes/signups.ts deleted file mode 100644 index 8ca792a..0000000 --- a/src/routes/signups.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { - backButton, - homeButton, - paginationRows, - row, -} from "@routes/lib/components"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; -import { Handler, Handlers } from "@routes/types"; -import { - RouteButtonBuilder, - RouteChannelSelectMenuBuilder, - RouteRoleSelectMenuBuilder, - RouteStringSelectMenuBuilder, - RouteStringSelectMenuOptionBuilder, -} from "discord-embed-router"; -import { - ActionRowBuilder, - ButtonStyle, - ChannelType, - EmbedBuilder, - Guild, - Interaction, - PermissionsBitField, -} from "discord.js"; - -import { - addVoiceChatRole, - addVoiceChatUser, - getUserVoiceChatSignups, - getVoiceChatRoleSignups, - removeVoiceChatRole, - removeVoiceChatUser, -} from "@db/voice-chats"; -import { joinWithAnd, mentionRole } from "@main/ring"; - -const COLOR = "#62a8ab"; -const PANEL = "/signups"; -const ROLES = "/signups/roles"; - -const mentionChannel = (channelId: string) => `<#${channelId}>`; - -const canManageRoleSignups = (interaction: Interaction): boolean => - interaction.memberPermissions?.has(PermissionsBitField.Flags.ManageRoles) === - true || - interaction.memberPermissions?.has( - PermissionsBitField.Flags.Administrator, - ) === true; - -const guildVoiceChannelIds = (guild: Guild): string[] => - [...guild.channels.cache.values()] - .filter((channel) => channel.isVoiceBased()) - .map((channel) => channel.id); - -// panels are ephemeral and per-guild; anything reached outside a guild -// (e.g. a DM) has nothing to show -const guildOnlyRender = { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("Signups") - .setDescription("⚠️ Signups only work inside a Discord server"), - ], - components: [], -}; - -// the user's signups, restricted to this guild's voice channels and sorted -// by channel name for stable paging -const guildSignups = async ( - userId: string, - guild: Guild, -): Promise => { - const channelIds = new Set(guildVoiceChannelIds(guild)); - return (await getUserVoiceChatSignups(userId)) - .filter((channelId) => channelIds.has(channelId)) - .sort((a, b) => - (guild.channels.cache.get(a)?.name ?? a).localeCompare( - guild.channels.cache.get(b)?.name ?? b, - ), - ); -}; - -const panelGet: Handler<"GET"> = async (router, interaction, state) => { - const guild = interaction.guild; - if (!guild) - return { ...guildOnlyRender, components: [row(homeButton(router))] }; - - const signups = await guildSignups(interaction.user.id, guild); - const { pageItems, page, pageCount } = paginate( - signups, - state.queryParams.get("page"), - ); - - const channelList = - pageItems.length > 0 ? pageItems.map(mentionChannel).join(" ") : "None"; - const description = withFlash( - state.queryParams, - "You get rung when someone starts a call in one of these channels.\n\n" + - `**Your signups in this server${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:** ${channelList}`, - ); - - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("Your signups") - .setDescription(description), - ], - components: [ - new ActionRowBuilder() - .addComponents( - new RouteChannelSelectMenuBuilder(router) - .setChannelTypes(ChannelType.GuildVoice) - .setMinValues(0) - .setMaxValues(PAGE_SIZE) - .setPlaceholder("Edit signups: select to add, deselect to remove") - .setDefaultChannels(...pageItems) - .setPattern(`${PANEL}/members`, { - method: "POST", - queryParams: { page: String(page) }, - }), - ) - .toJSON(), - row( - new RouteButtonBuilder(router) - .setLabel("Role signups") - .setStyle(ButtonStyle.Secondary) - .setTo(ROLES), - homeButton(router), - ), - ...paginationRows(router, PANEL, { page, pageCount }), - ], - }; -}; - -// serves the panel's diff select and the /signup, /unsignup and /quit -// adapters (which pass add/remove channel ids as query params) -const membersPost: Handler<"POST"> = async (router, interaction, state) => { - const guild = interaction.guild; - if (!guild) - return flashRedirect( - PANEL, - "Signups only work inside a Discord server", - "warn", - ); - - const userId = interaction.user.id; - const query = state.queryParams; - let addsRequested: string[]; - let removesRequested: string[]; - if (state.values) { - const signups = await guildSignups(userId, guild); - const { pageItems } = paginate(signups, query.get("page")); - ({ added: addsRequested, removed: removesRequested } = diffSelection({ - allItems: signups, - pageItems, - submitted: state.values, - })); - } else { - addsRequested = query.getAll("add"); - removesRequested = query.getAll("remove"); - } - - const added: string[] = []; - const alreadySignedUp: string[] = []; - for (const channelId of addsRequested) { - if (await addVoiceChatUser(channelId, userId)) added.push(channelId); - else alreadySignedUp.push(channelId); - } - const removed: string[] = []; - const notSignedUp: string[] = []; - for (const channelId of removesRequested) { - if (await removeVoiceChatUser(channelId, userId)) removed.push(channelId); - else notSignedUp.push(channelId); - } - - const parts = [ - ...(added.length > 0 - ? [`Signed up for ${joinWithAnd(added.map(mentionChannel))}`] - : []), - ...(alreadySignedUp.length > 0 - ? [ - `You are already signed up for ${joinWithAnd(alreadySignedUp.map(mentionChannel))}`, - ] - : []), - ...(removed.length > 0 - ? [ - `You will no longer be rung for ${joinWithAnd(removed.map(mentionChannel))}`, - ] - : []), - ...(notSignedUp.length > 0 - ? [ - `You aren't signed up for ${joinWithAnd(notSignedUp.map(mentionChannel))}`, - ] - : []), - ]; - const changed = added.length > 0 || removed.length > 0; - return flashRedirect( - PANEL, - parts.length > 0 ? parts.join(". ") : "No changes to your signups", - changed ? "success" : "warn", - { page: query.get("page") ?? "0" }, - ); -}; - -const noPermissionRender = (router: Parameters>[0]) => ({ - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("Role signups") - .setDescription( - "⚠️ You need the Manage Roles permission to manage role signups", - ), - ], - components: [row(backButton(router, PANEL), homeButton(router))], -}); - -// stable ordering for paging: by role name, then channel name -const sortedRoleSignups = async (guild: Guild) => { - const nameOf = (roleId: string, channelId: string) => - `${guild.roles.cache.get(roleId)?.name ?? roleId} ${guild.channels.cache.get(channelId)?.name ?? channelId}`; - return (await getVoiceChatRoleSignups(guildVoiceChannelIds(guild))).sort( - (a, b) => - nameOf(a.roleId, a.channelId).localeCompare( - nameOf(b.roleId, b.channelId), - ), - ); -}; - -const rolesGet: Handler<"GET"> = async (router, interaction, state) => { - const guild = interaction.guild; - if (!guild) - return { ...guildOnlyRender, components: [row(homeButton(router))] }; - if (!canManageRoleSignups(interaction)) return noPermissionRender(router); - - const mappings = await sortedRoleSignups(guild); - const pageStrings = mappings.map( - ({ roleId, channelId }) => `${roleId}:${channelId}`, - ); - const { pageItems, page, pageCount } = paginate( - pageStrings, - state.queryParams.get("page"), - ); - - const mappingLines = - pageItems.length > 0 - ? pageItems - .map((pair) => { - const [roleId = "", channelId = ""] = pair.split(":"); - return `${mentionRole(roleId)} → ${mentionChannel(channelId)}`; - }) - .join("\n") - : "None yet"; - const description = withFlash( - state.queryParams, - "When someone joins a channel, its signed-up roles get pinged.\n\n" + - `**Role signups${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:**\n${mappingLines}`, - ); - - const removeOptions = pageItems.map((pair) => { - const [roleId = "", channelId = ""] = pair.split(":"); - return new RouteStringSelectMenuOptionBuilder(router) - .setLabel( - `@${guild.roles.cache.get(roleId)?.name ?? roleId} → #${guild.channels.cache.get(channelId)?.name ?? channelId}`, - ) - .setTo(`${ROLES}/remove`, { queryParams: { pair } }); - }); - - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("Role signups") - .setDescription(description), - ], - components: [ - ...(removeOptions.length > 0 - ? [ - new ActionRowBuilder() - .addComponents( - new RouteStringSelectMenuBuilder(router) - .setTos(...removeOptions) - .setPlaceholder("Remove role signups") - .setMinValues(1) - .setMaxValues(removeOptions.length) - .setPattern(`${ROLES}/remove`, { - method: "POST", - queryParams: { page: String(page) }, - }), - ) - .toJSON(), - ] - : []), - new ActionRowBuilder() - .addComponents( - new RouteRoleSelectMenuBuilder(router) - .setMinValues(1) - .setMaxValues(1) - .setPlaceholder("Add a role signup: pick a role") - .setPattern(`${ROLES}/:roleId`), - ) - .toJSON(), - row(backButton(router, PANEL), homeButton(router)), - ...paginationRows(router, ROLES, { page, pageCount }), - ], - }; -}; - -// a remove-select value decodes to "?pair=:" -const pairOf = ( - value: string, -): { roleId: string; channelId: string } | null => { - const [, query = ""] = value.split("?"); - const [roleId, channelId] = - new URLSearchParams(query).get("pair")?.split(":") ?? []; - return roleId && channelId ? { roleId, channelId } : null; -}; - -// serves the panel's remove select and the /unsignuprole adapter (role and -// optional channel as query params; no channel removes the role everywhere) -const rolesRemovePost: Handler<"POST"> = async (router, interaction, state) => { - const guild = interaction.guild; - if (!guild) - return flashRedirect( - ROLES, - "Signups only work inside a Discord server", - "warn", - ); - if (!canManageRoleSignups(interaction)) - return flashRedirect( - ROLES, - "You need the Manage Roles permission to manage role signups", - "warn", - ); - - const query = state.queryParams; - let pairs: { roleId: string; channelId: string }[]; - if (state.values) { - pairs = state.values - .map(pairOf) - .filter((pair): pair is NonNullable => pair !== null); - } else { - const roleId = query.get("role") ?? ""; - const channelId = query.get("channel"); - pairs = channelId - ? [{ roleId, channelId }] - : (await sortedRoleSignups(guild)).filter( - (mapping) => mapping.roleId === roleId, - ); - if (pairs.length === 0) - return flashRedirect( - ROLES, - `${mentionRole(roleId)} isn't signed up for any voice channel`, - "warn", - ); - } - - const removed: typeof pairs = []; - const missing: typeof pairs = []; - for (const pair of pairs) { - if (await removeVoiceChatRole(pair.channelId, pair.roleId)) - removed.push(pair); - else missing.push(pair); - } - - const describe = (list: typeof pairs) => - joinWithAnd( - list.map( - ({ roleId, channelId }) => - `${mentionRole(roleId)} from ${mentionChannel(channelId)}`, - ), - ); - const parts = [ - ...(removed.length > 0 ? [`Removed ${describe(removed)}`] : []), - ...(missing.length > 0 ? [`Already removed: ${describe(missing)}`] : []), - ]; - return flashRedirect( - ROLES, - parts.length > 0 ? parts.join(". ") : "No role signups to remove", - removed.length > 0 ? "success" : "warn", - { page: query.get("page") ?? "0" }, - ); -}; - -const rolePickGet: Handler<"GET"> = async (router, interaction, state) => { - const guild = interaction.guild; - if (!guild) - return { ...guildOnlyRender, components: [row(homeButton(router))] }; - if (!canManageRoleSignups(interaction)) return noPermissionRender(router); - - const roleId = - typeof state.params.roleId === "string" ? state.params.roleId : ""; - - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("Add a role signup") - .setDescription( - withFlash( - state.queryParams, - `Signing up ${mentionRole(roleId)}: pick the voice channel it should be pinged for.`, - ), - ), - ], - components: [ - new ActionRowBuilder() - .addComponents( - new RouteChannelSelectMenuBuilder(router) - .setChannelTypes(ChannelType.GuildVoice) - .setMinValues(1) - .setMaxValues(1) - .setPlaceholder(`Pick a channel for the role`) - .setPattern(`${ROLES}/${roleId}`, { - method: "POST", - queryParams: { channel: ":channelId" }, - }), - ) - .toJSON(), - row(backButton(router, ROLES, "Cancel"), homeButton(router)), - ], - }; -}; - -const roleCommitPost: Handler<"POST"> = async (router, interaction, state) => { - if (!canManageRoleSignups(interaction)) - return flashRedirect( - ROLES, - "You need the Manage Roles permission to manage role signups", - "warn", - ); - - const roleId = - typeof state.params.roleId === "string" ? state.params.roleId : ""; - const channelId = state.queryParams.get("channel") ?? ""; - const channel = interaction.guild?.channels.cache.get(channelId); - if (!channel?.isVoiceBased()) - return flashRedirect( - `${ROLES}/${roleId}`, - "Pick a voice channel to finish the role signup", - "warn", - ); - - const added = await addVoiceChatRole(channelId, roleId); - return added - ? flashRedirect( - ROLES, - `Signed up ${mentionRole(roleId)} for ${mentionChannel(channelId)}`, - "success", - ) - : flashRedirect( - ROLES, - `${mentionRole(roleId)} is already signed up for ${mentionChannel(channelId)}`, - "warn", - ); -}; - -export const signupsHandlers = { - panel: { get: panelGet } satisfies Handlers, - members: { post: membersPost } satisfies Handlers, - roles: { get: rolesGet } satisfies Handlers, - rolesRemove: { post: rolesRemovePost } satisfies Handlers, - rolePage: { get: rolePickGet, post: roleCommitPost } satisfies Handlers, -}; diff --git a/src/routes/signups/_shared.ts b/src/routes/signups/_shared.ts new file mode 100644 index 0000000..c42f5c2 --- /dev/null +++ b/src/routes/signups/_shared.ts @@ -0,0 +1,83 @@ +import { backButton, homeButton, row } from "@routes/lib/components"; +import { Handler } from "@routes/types"; +import { + EmbedBuilder, + Guild, + Interaction, + PermissionsBitField, +} from "discord.js"; + +import { + getUserVoiceChatSignups, + getVoiceChatRoleSignups, +} from "@db/voice-chats"; + +export const COLOR = "#62a8ab"; +export const PANEL = "/signups"; +export const ROLES = "/signups/roles"; + +export const mentionChannel = (channelId: string) => `<#${channelId}>`; + +export const canManageRoleSignups = (interaction: Interaction): boolean => + interaction.memberPermissions?.has(PermissionsBitField.Flags.ManageRoles) === + true || + interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) === true; + +export const guildVoiceChannelIds = (guild: Guild): string[] => + [...guild.channels.cache.values()] + .filter((channel) => channel.isVoiceBased()) + .map((channel) => channel.id); + +// panels are ephemeral and per-guild; anything reached outside a guild +// (e.g. a DM) has nothing to show +export const guildOnlyRender = { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Signups") + .setDescription("⚠️ Signups only work inside a Discord server"), + ], + components: [], +}; + +// the user's signups, restricted to this guild's voice channels and sorted +// by channel name for stable paging +export const guildSignups = async ( + userId: string, + guild: Guild, +): Promise => { + const channelIds = new Set(guildVoiceChannelIds(guild)); + return (await getUserVoiceChatSignups(userId)) + .filter((channelId) => channelIds.has(channelId)) + .sort((a, b) => + (guild.channels.cache.get(a)?.name ?? a).localeCompare( + guild.channels.cache.get(b)?.name ?? b, + ), + ); +}; + +// stable ordering for paging: by role name, then channel name +export const sortedRoleSignups = async (guild: Guild) => { + const nameOf = (roleId: string, channelId: string) => + `${guild.roles.cache.get(roleId)?.name ?? roleId} ${guild.channels.cache.get(channelId)?.name ?? channelId}`; + return (await getVoiceChatRoleSignups(guildVoiceChannelIds(guild))).sort( + (a, b) => + nameOf(a.roleId, a.channelId).localeCompare( + nameOf(b.roleId, b.channelId), + ), + ); +}; + +export const noPermissionRender = (router: Parameters>[0]) => ({ + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Role signups") + .setDescription( + "⚠️ You need the Manage Roles permission to manage role signups", + ), + ], + components: [row(backButton(router, PANEL), homeButton(router))], +}); diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts new file mode 100644 index 0000000..352fc90 --- /dev/null +++ b/src/routes/signups/get.ts @@ -0,0 +1,80 @@ +import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, +} from "discord.js"; + +import { + COLOR, + guildOnlyRender, + guildSignups, + mentionChannel, + PANEL, + ROLES, +} from "./_shared"; + +export const signupsGet: Handler<"GET"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return { ...guildOnlyRender, components: [row(homeButton(router))] }; + + const signups = await guildSignups(interaction.user.id, guild); + const { pageItems, page, pageCount } = paginate( + signups, + state.queryParams.get("page"), + ); + + const channelList = + pageItems.length > 0 ? pageItems.map(mentionChannel).join(" ") : "None"; + const description = withFlash( + state.queryParams, + "You get rung when someone starts a call in one of these channels.\n\n" + + `**Your signups in this server${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:** ${channelList}`, + ); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Your signups") + .setDescription(description), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(PAGE_SIZE) + .setPlaceholder("Edit signups: select to add, deselect to remove") + .setDefaultChannels(...pageItems) + .setPattern(`${PANEL}/members`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON(), + row( + new RouteButtonBuilder(router) + .setLabel("Role signups") + .setStyle(ButtonStyle.Secondary) + .setTo(ROLES), + homeButton(router), + ), + ...paginationRows(router, PANEL, { page, pageCount }), + ], + }; +}; diff --git a/src/routes/signups/members/post.ts b/src/routes/signups/members/post.ts new file mode 100644 index 0000000..ee3dfb2 --- /dev/null +++ b/src/routes/signups/members/post.ts @@ -0,0 +1,82 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { diffSelection, paginate } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; + +import { addVoiceChatUser, removeVoiceChatUser } from "@db/voice-chats"; +import { joinWithAnd } from "@main/ring"; + +import { guildSignups, mentionChannel, PANEL } from "../_shared"; + +// serves the panel's diff select and the /signup, /unsignup and /quit +// adapters (which pass add/remove channel ids as query params) +export const signupsMembersPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return flashRedirect( + PANEL, + "Signups only work inside a Discord server", + "warn", + ); + + const userId = interaction.user.id; + const query = state.queryParams; + let addsRequested: string[]; + let removesRequested: string[]; + if (state.values) { + const signups = await guildSignups(userId, guild); + const { pageItems } = paginate(signups, query.get("page")); + ({ added: addsRequested, removed: removesRequested } = diffSelection({ + allItems: signups, + pageItems, + submitted: state.values, + })); + } else { + addsRequested = query.getAll("add"); + removesRequested = query.getAll("remove"); + } + + const added: string[] = []; + const alreadySignedUp: string[] = []; + for (const channelId of addsRequested) { + if (await addVoiceChatUser(channelId, userId)) added.push(channelId); + else alreadySignedUp.push(channelId); + } + const removed: string[] = []; + const notSignedUp: string[] = []; + for (const channelId of removesRequested) { + if (await removeVoiceChatUser(channelId, userId)) removed.push(channelId); + else notSignedUp.push(channelId); + } + + const parts = [ + ...(added.length > 0 + ? [`Signed up for ${joinWithAnd(added.map(mentionChannel))}`] + : []), + ...(alreadySignedUp.length > 0 + ? [ + `You are already signed up for ${joinWithAnd(alreadySignedUp.map(mentionChannel))}`, + ] + : []), + ...(removed.length > 0 + ? [ + `You will no longer be rung for ${joinWithAnd(removed.map(mentionChannel))}`, + ] + : []), + ...(notSignedUp.length > 0 + ? [ + `You aren't signed up for ${joinWithAnd(notSignedUp.map(mentionChannel))}`, + ] + : []), + ]; + const changed = added.length > 0 || removed.length > 0; + return flashRedirect( + PANEL, + parts.length > 0 ? parts.join(". ") : "No changes to your signups", + changed ? "success" : "warn", + { page: query.get("page") ?? "0" }, + ); +}; diff --git a/src/routes/signups/roles/[roleId]/get.ts b/src/routes/signups/roles/[roleId]/get.ts new file mode 100644 index 0000000..e4026ad --- /dev/null +++ b/src/routes/signups/roles/[roleId]/get.ts @@ -0,0 +1,59 @@ +import { backButton, homeButton, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; +import { RouteChannelSelectMenuBuilder } from "discord-embed-router"; +import { ActionRowBuilder, ChannelType, EmbedBuilder } from "discord.js"; + +import { mentionRole } from "@main/ring"; + +import { + canManageRoleSignups, + COLOR, + guildOnlyRender, + noPermissionRender, + ROLES, +} from "../../_shared"; + +export const signupsRoleGet: Handler<"GET"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!canManageRoleSignups(interaction)) return noPermissionRender(router); + + const roleId = + typeof state.params.roleId === "string" ? state.params.roleId : ""; + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Add a role signup") + .setDescription( + withFlash( + state.queryParams, + `Signing up ${mentionRole(roleId)}: pick the voice channel it should be pinged for.`, + ), + ), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(1) + .setMaxValues(1) + .setPlaceholder(`Pick a channel for the role`) + .setPattern(`${ROLES}/${roleId}`, { + method: "POST", + queryParams: { channel: ":channelId" }, + }), + ) + .toJSON(), + row(backButton(router, ROLES, "Cancel"), homeButton(router)), + ], + }; +}; diff --git a/src/routes/signups/roles/[roleId]/post.ts b/src/routes/signups/roles/[roleId]/post.ts new file mode 100644 index 0000000..dd07f9c --- /dev/null +++ b/src/routes/signups/roles/[roleId]/post.ts @@ -0,0 +1,44 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { addVoiceChatRole } from "@db/voice-chats"; +import { mentionRole } from "@main/ring"; + +import { canManageRoleSignups, mentionChannel, ROLES } from "../../_shared"; + +export const signupsRolePost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + if (!canManageRoleSignups(interaction)) + return flashRedirect( + ROLES, + "You need the Manage Roles permission to manage role signups", + "warn", + ); + + const roleId = + typeof state.params.roleId === "string" ? state.params.roleId : ""; + const channelId = state.queryParams.get("channel") ?? ""; + const channel = interaction.guild?.channels.cache.get(channelId); + if (!channel?.isVoiceBased()) + return flashRedirect( + `${ROLES}/${roleId}`, + "Pick a voice channel to finish the role signup", + "warn", + ); + + const added = await addVoiceChatRole(channelId, roleId); + return added + ? flashRedirect( + ROLES, + `Signed up ${mentionRole(roleId)} for ${mentionChannel(channelId)}`, + "success", + ) + : flashRedirect( + ROLES, + `${mentionRole(roleId)} is already signed up for ${mentionChannel(channelId)}`, + "warn", + ); +}; diff --git a/src/routes/signups/roles/get.ts b/src/routes/signups/roles/get.ts new file mode 100644 index 0000000..76cc088 --- /dev/null +++ b/src/routes/signups/roles/get.ts @@ -0,0 +1,111 @@ +import { + backButton, + homeButton, + paginationRows, + row, +} from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { paginate } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; +import { + RouteRoleSelectMenuBuilder, + RouteStringSelectMenuBuilder, + RouteStringSelectMenuOptionBuilder, +} from "discord-embed-router"; +import { ActionRowBuilder, EmbedBuilder } from "discord.js"; + +import { mentionRole } from "@main/ring"; + +import { + canManageRoleSignups, + COLOR, + guildOnlyRender, + mentionChannel, + noPermissionRender, + PANEL, + ROLES, + sortedRoleSignups, +} from "../_shared"; + +export const signupsRolesGet: Handler<"GET"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!canManageRoleSignups(interaction)) return noPermissionRender(router); + + const mappings = await sortedRoleSignups(guild); + const pageStrings = mappings.map( + ({ roleId, channelId }) => `${roleId}:${channelId}`, + ); + const { pageItems, page, pageCount } = paginate( + pageStrings, + state.queryParams.get("page"), + ); + + const mappingLines = + pageItems.length > 0 + ? pageItems + .map((pair) => { + const [roleId = "", channelId = ""] = pair.split(":"); + return `${mentionRole(roleId)} → ${mentionChannel(channelId)}`; + }) + .join("\n") + : "None yet"; + const description = withFlash( + state.queryParams, + "When someone joins a channel, its signed-up roles get pinged.\n\n" + + `**Role signups${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:**\n${mappingLines}`, + ); + + const removeOptions = pageItems.map((pair) => { + const [roleId = "", channelId = ""] = pair.split(":"); + return new RouteStringSelectMenuOptionBuilder(router) + .setLabel( + `@${guild.roles.cache.get(roleId)?.name ?? roleId} → #${guild.channels.cache.get(channelId)?.name ?? channelId}`, + ) + .setTo(`${ROLES}/remove`, { queryParams: { pair } }); + }); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("Role signups") + .setDescription(description), + ], + components: [ + ...(removeOptions.length > 0 + ? [ + new ActionRowBuilder() + .addComponents( + new RouteStringSelectMenuBuilder(router) + .setTos(...removeOptions) + .setPlaceholder("Remove role signups") + .setMinValues(1) + .setMaxValues(removeOptions.length) + .setPattern(`${ROLES}/remove`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON(), + ] + : []), + new ActionRowBuilder() + .addComponents( + new RouteRoleSelectMenuBuilder(router) + .setMinValues(1) + .setMaxValues(1) + .setPlaceholder("Add a role signup: pick a role") + .setPattern(`${ROLES}/:roleId`), + ) + .toJSON(), + row(backButton(router, PANEL), homeButton(router)), + ...paginationRows(router, ROLES, { page, pageCount }), + ], + }; +}; diff --git a/src/routes/signups/roles/remove/post.ts b/src/routes/signups/roles/remove/post.ts new file mode 100644 index 0000000..6944a13 --- /dev/null +++ b/src/routes/signups/roles/remove/post.ts @@ -0,0 +1,92 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { removeVoiceChatRole } from "@db/voice-chats"; +import { joinWithAnd, mentionRole } from "@main/ring"; + +import { + canManageRoleSignups, + mentionChannel, + ROLES, + sortedRoleSignups, +} from "../../_shared"; + +// a remove-select value decodes to "?pair=:" +const pairOf = ( + value: string, +): { roleId: string; channelId: string } | null => { + const [, query = ""] = value.split("?"); + const [roleId, channelId] = + new URLSearchParams(query).get("pair")?.split(":") ?? []; + return roleId && channelId ? { roleId, channelId } : null; +}; + +// serves the panel's remove select and the /unsignuprole adapter (role and +// optional channel as query params; no channel removes the role everywhere) +export const signupsRolesRemovePost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return flashRedirect( + ROLES, + "Signups only work inside a Discord server", + "warn", + ); + if (!canManageRoleSignups(interaction)) + return flashRedirect( + ROLES, + "You need the Manage Roles permission to manage role signups", + "warn", + ); + + const query = state.queryParams; + let pairs: { roleId: string; channelId: string }[]; + if (state.values) { + pairs = state.values + .map(pairOf) + .filter((pair): pair is NonNullable => pair !== null); + } else { + const roleId = query.get("role") ?? ""; + const channelId = query.get("channel"); + pairs = channelId + ? [{ roleId, channelId }] + : (await sortedRoleSignups(guild)).filter( + (mapping) => mapping.roleId === roleId, + ); + if (pairs.length === 0) + return flashRedirect( + ROLES, + `${mentionRole(roleId)} isn't signed up for any voice channel`, + "warn", + ); + } + + const removed: typeof pairs = []; + const missing: typeof pairs = []; + for (const pair of pairs) { + if (await removeVoiceChatRole(pair.channelId, pair.roleId)) + removed.push(pair); + else missing.push(pair); + } + + const describe = (list: typeof pairs) => + joinWithAnd( + list.map( + ({ roleId, channelId }) => + `${mentionRole(roleId)} from ${mentionChannel(channelId)}`, + ), + ); + const parts = [ + ...(removed.length > 0 ? [`Removed ${describe(removed)}`] : []), + ...(missing.length > 0 ? [`Already removed: ${describe(missing)}`] : []), + ]; + return flashRedirect( + ROLES, + parts.length > 0 ? parts.join(". ") : "No role signups to remove", + removed.length > 0 ? "success" : "warn", + { page: query.get("page") ?? "0" }, + ); +}; diff --git a/src/routes/signups.test.ts b/src/routes/signups/signups.test.ts similarity index 87% rename from src/routes/signups.test.ts rename to src/routes/signups/signups.test.ts index 11c847b..d2e5560 100644 --- a/src/routes/signups.test.ts +++ b/src/routes/signups/signups.test.ts @@ -1,9 +1,10 @@ -import { signupsHandlers } from "@routes/signups"; import { Interaction, PermissionsBitField } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { addVoiceChatRole } from "@db/voice-chats"; +import { signupsRolePost } from "./roles/[roleId]/post"; + vi.mock("@db/voice-chats", () => ({ getUserVoiceChatSignups: vi.fn(), getVoiceChatRoleSignups: vi.fn(), @@ -23,14 +24,13 @@ const makeInteraction = (admin: boolean) => guild: { channels: { cache: new Map([[voiceChannel.id, voiceChannel]]) } }, }) as unknown as Interaction; -type RoleCommitPost = NonNullable; const commitState = (query: string) => ({ params: { roleId: "role1" }, path: "/signups/roles/role1", queryParams: new URLSearchParams(query), timestamp: 0, - }) as unknown as Parameters[2]; + }) as unknown as Parameters[2]; beforeEach(() => { vi.clearAllMocks(); @@ -39,7 +39,7 @@ beforeEach(() => { test("committing a role signup that already exists rejects with a flash", async () => { vi.mocked(addVoiceChatRole).mockResolvedValue(false); - const result = await signupsHandlers.rolePage.post!( + const result = await signupsRolePost( undefined as never, makeInteraction(true), commitState("channel=vc1"), @@ -56,7 +56,7 @@ test("committing a role signup that already exists rejects with a flash", async test("committing a role signup succeeds and reports the mapping", async () => { vi.mocked(addVoiceChatRole).mockResolvedValue(true); - const result = await signupsHandlers.rolePage.post!( + const result = await signupsRolePost( undefined as never, makeInteraction(true), commitState("channel=vc1"), @@ -71,7 +71,7 @@ test("committing a role signup succeeds and reports the mapping", async () => { }); test("a role signup commit without Manage Roles mutates nothing", async () => { - const result = await signupsHandlers.rolePage.post!( + const result = await signupsRolePost( undefined as never, makeInteraction(false), commitState("channel=vc1"), diff --git a/src/routes/types.ts b/src/routes/types.ts index fccbb64..89a287a 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -3,7 +3,6 @@ import { Method, RouteButtonBuilder, RouteHandler, - RouteHandlers, RouteModalBuilder, } from "discord-embed-router"; @@ -21,10 +20,6 @@ export type Handler = RingRouter extends EmbedRouter ? RouteHandler : never; -export type Handlers = - RingRouter extends EmbedRouter - ? RouteHandlers - : never; // nameable alias for helper return types (declaration emit can't name the // library's internal Unused symbol that Session/Locals default to) From 87dfceab421efb7137d59909ed2f2f6321fa4437 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:59:44 -0400 Subject: [PATCH 15/82] reorganize routes to mirror paths: folder per segment, file per method Handlers live at their route's path ([param] folders, get/post/modal.ts files) and index.ts is a plain routing table importing each one directly. Per-panel helpers sit in _shared.ts files. Also unifies the reset naming (recipients /clear and clearDefaultRingees become /reset and resetDefaultRingees) and moves the delete-data confirmation modal onto /delete-data itself instead of a /confirm subpath. Co-Authored-By: Claude Fable 5 --- src/main/db/default-ringees.ts | 2 +- src/routes/delete-data/get.ts | 2 +- src/routes/delete-data/{confirm => }/modal.ts | 4 ++-- src/routes/index.ts | 19 ++++++++------- src/routes/recipients/[scope]/get.ts | 2 +- .../[scope]/{clear => reset}/modal.ts | 4 ++-- .../[scope]/{clear => reset}/post.ts | 8 +++---- src/routes/recipients/recipients.test.ts | 24 +++++++++---------- 8 files changed, 34 insertions(+), 31 deletions(-) rename src/routes/delete-data/{confirm => }/modal.ts (61%) rename src/routes/recipients/[scope]/{clear => reset}/modal.ts (73%) rename src/routes/recipients/[scope]/{clear => reset}/post.ts (81%) diff --git a/src/main/db/default-ringees.ts b/src/main/db/default-ringees.ts index 61a7eb5..e8c2c52 100644 --- a/src/main/db/default-ringees.ts +++ b/src/main/db/default-ringees.ts @@ -78,7 +78,7 @@ export const removeDefaultRingee = async ( }; // returns whether the list had any ringees -export const clearDefaultRingees = async ( +export const resetDefaultRingees = async ( userId: string, channelId: string | null, ): Promise => { diff --git a/src/routes/delete-data/get.ts b/src/routes/delete-data/get.ts index 3c78b94..76baac2 100644 --- a/src/routes/delete-data/get.ts +++ b/src/routes/delete-data/get.ts @@ -31,7 +31,7 @@ export const deleteDataGet: Handler<"GET"> = (router, interaction, state) => { .setLabel("Delete") .setStyle(ButtonStyle.Danger) .setDisabled(done) - .setTo(`${PANEL}/confirm`, { method: "MODAL" }), + .setTo(PANEL, { method: "MODAL" }), new RouteButtonBuilder(router) .setLabel("Cancel") .setStyle(ButtonStyle.Secondary) diff --git a/src/routes/delete-data/confirm/modal.ts b/src/routes/delete-data/modal.ts similarity index 61% rename from src/routes/delete-data/confirm/modal.ts rename to src/routes/delete-data/modal.ts index 98facb1..f2ee9d6 100644 --- a/src/routes/delete-data/confirm/modal.ts +++ b/src/routes/delete-data/modal.ts @@ -1,9 +1,9 @@ import { confirmModal } from "@routes/lib/confirm"; import { Handler } from "@routes/types"; -import { CONFIRMATION, PANEL } from "../_shared"; +import { CONFIRMATION, PANEL } from "./_shared"; -export const deleteDataConfirmModal: Handler<"MODAL"> = (router) => +export const deleteDataModal: Handler<"MODAL"> = (router) => confirmModal(router, { to: PANEL, title: "Delete all data", diff --git a/src/routes/index.ts b/src/routes/index.ts index 3b0c721..1b3d103 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,8 +1,8 @@ import { RingRouter } from "@routes/types"; import { commandsGet } from "./commands/get"; -import { deleteDataConfirmModal } from "./delete-data/confirm/modal"; import { deleteDataGet } from "./delete-data/get"; +import { deleteDataModal } from "./delete-data/modal"; import { deleteDataPost } from "./delete-data/post"; import { filterGet } from "./filter/[scope]/get"; import { filterMembersPost } from "./filter/[scope]/members/post"; @@ -14,10 +14,10 @@ import { modeGet } from "./mode/get"; import { modePost } from "./mode/post"; import { recipientsAutoRingPost } from "./recipients/[scope]/auto-ring/post"; import { recipientsAutoRingUnsetPost } from "./recipients/[scope]/auto-ring/unset/post"; -import { recipientsClearModal } from "./recipients/[scope]/clear/modal"; -import { recipientsClearPost } from "./recipients/[scope]/clear/post"; import { recipientsGet } from "./recipients/[scope]/get"; import { recipientsMembersPost } from "./recipients/[scope]/members/post"; +import { recipientsResetModal } from "./recipients/[scope]/reset/modal"; +import { recipientsResetPost } from "./recipients/[scope]/reset/post"; import { ringDefaultPost } from "./ring/default/post"; import { ringGet } from "./ring/get"; import { ringUserPost } from "./ring/user/post"; @@ -48,9 +48,9 @@ export const registerRoutes = (router: RingRouter) => { router.route(["/recipients", "/recipients/:scope"], { get: recipientsGet }); router.route("/recipients/:scope/members", { post: recipientsMembersPost }); - router.route("/recipients/:scope/clear", { - modal: recipientsClearModal, - post: recipientsClearPost, + router.route("/recipients/:scope/reset", { + modal: recipientsResetModal, + post: recipientsResetPost, }); router.route("/recipients/:scope/auto-ring", { post: recipientsAutoRingPost, @@ -76,6 +76,9 @@ export const registerRoutes = (router: RingRouter) => { router.route("/ring/user", { post: ringUserPost }); router.route("/ring/default", { post: ringDefaultPost }); - router.route("/delete-data", { get: deleteDataGet, post: deleteDataPost }); - router.route("/delete-data/confirm", { modal: deleteDataConfirmModal }); + router.route("/delete-data", { + get: deleteDataGet, + post: deleteDataPost, + modal: deleteDataModal, + }); }; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 998f70d..0a966c8 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -119,7 +119,7 @@ export const recipientsGet: Handler<"GET"> = async ( new RouteButtonBuilder(router) .setLabel("Reset") .setStyle(ButtonStyle.Danger) - .setTo(`${panelPath(scope)}/clear`, { method: "MODAL" }), + .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), new RouteButtonBuilder(router) .setLabel( autoRing.effective ? "Disable auto-ring" : "Enable auto-ring", diff --git a/src/routes/recipients/[scope]/clear/modal.ts b/src/routes/recipients/[scope]/reset/modal.ts similarity index 73% rename from src/routes/recipients/[scope]/clear/modal.ts rename to src/routes/recipients/[scope]/reset/modal.ts index 8f89983..1bef557 100644 --- a/src/routes/recipients/[scope]/clear/modal.ts +++ b/src/routes/recipients/[scope]/reset/modal.ts @@ -4,13 +4,13 @@ import { Handler } from "@routes/types"; import { panelPath } from "../../_shared"; -export const recipientsClearModal: Handler<"MODAL"> = ( +export const recipientsResetModal: Handler<"MODAL"> = ( router, interaction, state, ) => confirmModal(router, { - to: `${panelPath(scopeOf(state.params))}/clear`, + to: `${panelPath(scopeOf(state.params))}/reset`, title: "Reset ring recipients", word: "RESET", }); diff --git a/src/routes/recipients/[scope]/clear/post.ts b/src/routes/recipients/[scope]/reset/post.ts similarity index 81% rename from src/routes/recipients/[scope]/clear/post.ts rename to src/routes/recipients/[scope]/reset/post.ts index 2e20a4e..edcbbb4 100644 --- a/src/routes/recipients/[scope]/clear/post.ts +++ b/src/routes/recipients/[scope]/reset/post.ts @@ -3,11 +3,11 @@ import { flashRedirect } from "@routes/lib/flash"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; -import { clearDefaultRingees } from "@db/default-ringees"; +import { resetDefaultRingees } from "@db/default-ringees"; import { panelPath, scopeSuffix } from "../../_shared"; -export const recipientsClearPost: Handler<"POST"> = async ( +export const recipientsResetPost: Handler<"POST"> = async ( router, interaction, state, @@ -19,11 +19,11 @@ export const recipientsClearPost: Handler<"POST"> = async ( "Confirmation text did not match, your recipients were not cleared", "warn", ); - const cleared = await clearDefaultRingees( + const hadRingees = await resetDefaultRingees( interaction.user.id, channelIdOf(scope), ); - return cleared + return hadRingees ? flashRedirect( panelPath(scope), `Cleared your default ring recipients ${scopeSuffix(scope)}`, diff --git a/src/routes/recipients/recipients.test.ts b/src/routes/recipients/recipients.test.ts index 0a10e71..e311228 100644 --- a/src/routes/recipients/recipients.test.ts +++ b/src/routes/recipients/recipients.test.ts @@ -2,11 +2,11 @@ import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { setAutoRing, unsetAutoRing } from "@db/auto-ring"; -import { clearDefaultRingees } from "@db/default-ringees"; +import { resetDefaultRingees } from "@db/default-ringees"; import { recipientsAutoRingPost } from "./[scope]/auto-ring/post"; import { recipientsAutoRingUnsetPost } from "./[scope]/auto-ring/unset/post"; -import { recipientsClearPost } from "./[scope]/clear/post"; +import { recipientsResetPost } from "./[scope]/reset/post"; vi.mock("@db/auto-ring", () => ({ getAutoRingSetting: vi.fn(), @@ -17,7 +17,7 @@ vi.mock("@db/default-ringees", () => ({ getDefaultRingees: vi.fn(), addDefaultRingee: vi.fn(), removeDefaultRingee: vi.fn(), - clearDefaultRingees: vi.fn(), + resetDefaultRingees: vi.fn(), })); const interaction = { user: { id: "caller" } } as unknown as Interaction; @@ -30,25 +30,25 @@ const autoRingState = (scope: string, query: string) => timestamp: 0, }) as unknown as Parameters[2]; -const clearPost = (confirmation: string) => - recipientsClearPost(undefined as never, interaction, { +const resetPost = (confirmation: string) => + recipientsResetPost(undefined as never, interaction, { params: { scope: "global" }, - path: "/recipients/global/clear", + path: "/recipients/global/reset", queryParams: new URLSearchParams(), timestamp: 0, fields: { getTextInputValue: () => confirmation }, - } as unknown as Parameters[2]); + } as unknown as Parameters[2]); beforeEach(() => { vi.clearAllMocks(); }); test("a recipients reset with matching confirmation text clears the list", async () => { - vi.mocked(clearDefaultRingees).mockResolvedValue(true); + vi.mocked(resetDefaultRingees).mockResolvedValue(true); - const result = await clearPost("RESET"); + const result = await resetPost("RESET"); - expect(clearDefaultRingees).toHaveBeenCalledExactlyOnceWith("caller", null); + expect(resetDefaultRingees).toHaveBeenCalledExactlyOnceWith("caller", null); const flashParams = new URLSearchParams( result.queryParams as Record, ); @@ -56,9 +56,9 @@ test("a recipients reset with matching confirmation text clears the list", async }); test("a recipients reset without matching confirmation text mutates nothing", async () => { - const result = await clearPost("nope"); + const result = await resetPost("nope"); - expect(clearDefaultRingees).not.toHaveBeenCalled(); + expect(resetDefaultRingees).not.toHaveBeenCalled(); const flashParams = new URLSearchParams( result.queryParams as Record, ); From 8b45fe694fdad36b12bde25adca5ea6081ae3e19 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:02:56 -0400 Subject: [PATCH 16/82] add /catalog to open the command list directly Co-Authored-By: Claude Fable 5 --- src/commands/catalog.ts | 17 +++++++++++++++++ src/commands/commandNames.ts | 1 + src/commands/commands.ts | 2 ++ src/routes/commands/get.ts | 1 + 4 files changed, 21 insertions(+) create mode 100644 src/commands/catalog.ts diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts new file mode 100644 index 0000000..685115c --- /dev/null +++ b/src/commands/catalog.ts @@ -0,0 +1,17 @@ +import { RingRouter } from "@routes/types"; +import { + ChatInputCommandInteraction, + MessageFlags, + SlashCommandBuilder, +} from "discord.js"; + +export const catalog = { + data: new SlashCommandBuilder() + .setName("catalog") + .setDescription("List every RingVC command"), + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await router.dispatch(interaction, "/commands", { + flags: [MessageFlags.Ephemeral], + }); + }, +}; diff --git a/src/commands/commandNames.ts b/src/commands/commandNames.ts index e8f70f3..7409f4e 100644 --- a/src/commands/commandNames.ts +++ b/src/commands/commandNames.ts @@ -1,5 +1,6 @@ export const commandNamesList = [ "help", + "catalog", "delete_data", "ring", "signup", diff --git a/src/commands/commands.ts b/src/commands/commands.ts index af52cb2..2f51c4d 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,6 +1,7 @@ import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, SharedSlashCommand } from "discord.js"; +import { catalog } from "@commands/catalog"; import { defaultRingRecipients } from "@commands/defaultRingRecipient"; import { deleteData } from "@commands/deleteData"; import { block } from "@commands/filter/block"; @@ -29,6 +30,7 @@ export type CommandImplementation = { export const commands: CommandImplementation[] = [ help, + catalog, deleteData, ring, signup, diff --git a/src/routes/commands/get.ts b/src/routes/commands/get.ts index 182f2c3..285e4d9 100644 --- a/src/routes/commands/get.ts +++ b/src/routes/commands/get.ts @@ -24,6 +24,7 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { ]; const panels: [CommandName, string][] = [ ["help", "the home panel"], + ["catalog", "this command list"], ["signup", "your signups panel (bare, outside a voice channel)"], ["filter", "your filter panel"], ["default_ring_recipients", "ring recipients and auto-ring panel"], From 539b6462a7db012ebc3099eedc1bc794a2ecb85d Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:13:08 -0400 Subject: [PATCH 17/82] make CI's npm match the npm 11 that maintains the lockfile node 22's bundled npm 10 computes a different ideal tree for vite's optional esbuild peer, so a lockfile written by npm 11 fails npm ci under npm 10 (and vice versa). Upgrade npm in CI and normalize the lockfile with npm 11. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yaml | 3 +++ package-lock.json | 42 --------------------------------------- 2 files changed, 3 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 167dc67..243d3b8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,6 +18,9 @@ jobs: with: node-version: 22 cache: npm + # the lockfile is maintained with npm 11, which resolves vite's + # optional esbuild peer differently than node 22's bundled npm 10 + - run: npm install -g npm@11 - run: npm ci - run: npm run ${{ matrix.check }} diff --git a/package-lock.json b/package-lock.json index c8420ae..8e71e76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1375,9 +1375,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1395,9 +1392,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1415,9 +1409,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1435,9 +1426,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1455,9 +1443,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1475,9 +1460,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1971,9 +1953,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1988,9 +1967,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2005,9 +1981,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2022,9 +1995,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4105,9 +4075,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4129,9 +4096,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4153,9 +4117,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4177,9 +4138,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ From 7fb9cf0b6cf3f92903eb693341a63e1ef131c8df Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:50:24 -0400 Subject: [PATCH 18/82] update discord embed router --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8e71e76..c9c591b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "dependencies": { "@supabase/supabase-js": "^2.110.2", - "discord-embed-router": "^1.1.0", + "discord-embed-router": "^1.2.0", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", @@ -2946,9 +2946,9 @@ ] }, "node_modules/discord-embed-router": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.1.0.tgz", - "integrity": "sha512-53q56q6Xb6uCcfXnmoHFa9ZyJJThphzykl7qbaLN5ErAjsj8LOkR8/fJMJFa9WP4nvDZFV8WHvoFyE+Cx9HH4Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.2.0.tgz", + "integrity": "sha512-r01bpZYN7xuydAKxIIYYed0JpygBCZgCL8Zky17eaBcfpkPEFlhuQjz5WFvxysk7eGfGalXx/oIEPu5c3RC1vw==", "license": "MIT", "workspaces": [ "examples/basic-bot" diff --git a/package.json b/package.json index a4831ae..7d4f5e0 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@supabase/supabase-js": "^2.110.2", - "discord-embed-router": "^1.1.0", + "discord-embed-router": "^1.2.0", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", From f58c5f7c215d3b0e311168566803bdbbb4f8c88f Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:17:50 -0400 Subject: [PATCH 19/82] redesign embed panels into one consistent system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the embed redesign handoff across all panels: harmonized accent colors, unicode-emoji titles, `Label · value` status lines, and a consistent final nav row (Home last, on its own row) via a new navRow helper. Render the flash notice as a bolded-lead markdown blockquote. Rework role signups from the two-step add into a scoped panel like filter: pick a scope (by-channel or by-role, as path segments), view its links, and edit them inline with one diffed multi-select. The /signuprole and /unsignuprole commands drive the same edit routes via add/remove query params, mirroring the filter members pattern. Batch (multi-scope) editing is intentionally left out: panel state lives entirely in the customId and Discord caps it at 100 chars, which a multi-id scope path blows past at ~4 ids. Add branded ringvc/vc emoji on Home, guarded on client availability so an unresolvable emoji falls back to none. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/signuprole.ts | 18 +- src/commands/unsignuprole.ts | 16 +- src/routes/_shared.ts | 2 +- src/routes/commands/get.ts | 8 +- src/routes/delete-data/get.ts | 8 +- src/routes/filter/[scope]/get.ts | 12 +- src/routes/get.ts | 68 +++---- src/routes/index.ts | 34 +++- src/routes/lib/components.ts | 11 ++ src/routes/lib/emoji.ts | 26 +++ src/routes/lib/flash.test.ts | 17 +- src/routes/lib/flash.ts | 22 ++- src/routes/mode/get.ts | 20 +- src/routes/recipients/[scope]/get.ts | 34 ++-- src/routes/ring/get.ts | 12 +- src/routes/signups/_shared.ts | 2 +- src/routes/signups/get.ts | 13 +- src/routes/signups/roles/[roleId]/get.ts | 59 ------ src/routes/signups/roles/[roleId]/post.ts | 44 ----- src/routes/signups/roles/_shared.ts | 178 ++++++++++++++++++ src/routes/signups/roles/by-channel/get.ts | 93 +++++++++ .../signups/roles/by-channel/roles/post.ts | 75 ++++++++ .../signups/roles/by-role/channels/post.ts | 84 +++++++++ src/routes/signups/roles/by-role/get.ts | 99 ++++++++++ src/routes/signups/roles/get.ts | 111 ----------- src/routes/signups/roles/remove/post.ts | 92 --------- src/routes/signups/signups.test.ts | 126 +++++++++---- 27 files changed, 826 insertions(+), 458 deletions(-) create mode 100644 src/routes/lib/emoji.ts delete mode 100644 src/routes/signups/roles/[roleId]/get.ts delete mode 100644 src/routes/signups/roles/[roleId]/post.ts create mode 100644 src/routes/signups/roles/_shared.ts create mode 100644 src/routes/signups/roles/by-channel/get.ts create mode 100644 src/routes/signups/roles/by-channel/roles/post.ts create mode 100644 src/routes/signups/roles/by-role/channels/post.ts create mode 100644 src/routes/signups/roles/by-role/get.ts delete mode 100644 src/routes/signups/roles/get.ts delete mode 100644 src/routes/signups/roles/remove/post.ts diff --git a/src/commands/signuprole.ts b/src/commands/signuprole.ts index 5bf2aee..5016efb 100644 --- a/src/commands/signuprole.ts +++ b/src/commands/signuprole.ts @@ -30,17 +30,21 @@ export const signuprole = { (interaction.channel?.type === ChannelType.GuildVoice ? interaction.channel : null); - // without a channel, land on the role's channel-pick page + // without a channel, open the role's scoped panel to pick channels if (!channel) { - await router.dispatch(interaction, `/signups/roles/${role.id}`, { + await router.dispatch(interaction, `/signups/roles/by-role/${role.id}`, { flags: [MessageFlags.Ephemeral], }); return; } - await router.dispatch(interaction, `/signups/roles/${role.id}`, { - method: "POST", - queryParams: { channel: channel.id }, - flags: [MessageFlags.Ephemeral], - }); + await router.dispatch( + interaction, + `/signups/roles/by-channel/${channel.id}/roles`, + { + method: "POST", + queryParams: { add: role.id }, + flags: [MessageFlags.Ephemeral], + }, + ); }, }; diff --git a/src/commands/unsignuprole.ts b/src/commands/unsignuprole.ts index c5704d8..0421e6e 100644 --- a/src/commands/unsignuprole.ts +++ b/src/commands/unsignuprole.ts @@ -28,13 +28,15 @@ export const unsignuprole = { async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { const role = interaction.options.getRole("role", true); const channel = interaction.options.getChannel("channel"); - await router.dispatch(interaction, "/signups/roles/remove", { - method: "POST", - queryParams: { - role: role.id, - ...(channel ? { channel: channel.id } : {}), + // a channel removes just that link; no channel clears every channel + await router.dispatch( + interaction, + `/signups/roles/by-role/${role.id}/channels`, + { + method: "POST", + queryParams: channel ? { remove: channel.id } : { removeAll: "1" }, + flags: [MessageFlags.Ephemeral], }, - flags: [MessageFlags.Ephemeral], - }); + ); }, }; diff --git a/src/routes/_shared.ts b/src/routes/_shared.ts index 9573375..ac30fab 100644 --- a/src/routes/_shared.ts +++ b/src/routes/_shared.ts @@ -1 +1 @@ -export const COLOR = "#b574c5"; +export const COLOR = "#a082c3"; diff --git a/src/routes/commands/get.ts b/src/routes/commands/get.ts index 285e4d9..57ce3eb 100644 --- a/src/routes/commands/get.ts +++ b/src/routes/commands/get.ts @@ -1,4 +1,4 @@ -import { homeButton, row } from "@routes/lib/components"; +import { navRow } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; import { Handler } from "@routes/types"; @@ -6,7 +6,7 @@ import { EmbedBuilder } from "discord.js"; import { CommandName } from "@commands/commandNames"; -import { COLOR } from "../_shared"; +const COLOR = "#6197cd"; export const commandsGet: Handler<"GET"> = (router, interaction, state) => { const mention = (name: CommandName) => commandMention(state.globals, name); @@ -39,7 +39,7 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { embeds: [ new EmbedBuilder() .setColor(COLOR) - .setTitle("Commands") + .setTitle("📖 Commands") .setDescription( withFlash(state.queryParams, "Every command is clickable."), ) @@ -48,6 +48,6 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { { name: "Panel openers", value: list(panels) }, ), ], - components: [row(homeButton(router))], + components: [navRow(router)], }; }; diff --git a/src/routes/delete-data/get.ts b/src/routes/delete-data/get.ts index 76baac2..2a19739 100644 --- a/src/routes/delete-data/get.ts +++ b/src/routes/delete-data/get.ts @@ -6,7 +6,7 @@ import { ButtonStyle, EmbedBuilder } from "discord.js"; import { PANEL } from "./_shared"; -const COLOR = "#ca2b2b"; +const COLOR = "#da373c"; export const deleteDataGet: Handler<"GET"> = (router, interaction, state) => { // set by the deletion redirect: the panel has done its job, so its @@ -17,18 +17,18 @@ export const deleteDataGet: Handler<"GET"> = (router, interaction, state) => { embeds: [ new EmbedBuilder() .setColor(COLOR) - .setTitle("Delete all data") + .setTitle("🗑️ Delete all data") .setDescription( withFlash( state.queryParams, - "Are you sure you want to delete all your data? This will remove all your filters, signups, and other account settings. This is irreversible.", + "Are you sure you want to delete all your data? This removes your filters, signups, and other account settings. **This can't be undone.**", ), ), ], components: [ row( new RouteButtonBuilder(router) - .setLabel("Delete") + .setLabel("Delete everything") .setStyle(ButtonStyle.Danger) .setDisabled(done) .setTo(PANEL, { method: "MODAL" }), diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index bcee272..0568573 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -1,4 +1,4 @@ -import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { navRow, paginationRows, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { PAGE_SIZE, paginate } from "@routes/lib/paging"; import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; @@ -20,7 +20,7 @@ import { mentionUser } from "@main/ring"; import { panelPath } from "../_shared"; -const COLOR = "#94ab62"; +const COLOR = "#63a471"; export const filterGet: Handler<"GET"> = async (router, interaction, state) => { const scope = scopeOf(state.params); @@ -41,14 +41,16 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { (type === "whitelist" ? "Only the people listed here can ring you, and you only ring them." : "The people listed here can't ring you, and you won't ring them.") + - `\n\n**Members${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:** ${memberList}`, + `\n\n**Members${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${memberList}`, ); return { embeds: [ new EmbedBuilder() .setColor(COLOR) - .setTitle(scope === "global" ? "Your global filter" : "Channel filter") + .setTitle( + scope === "global" ? "🛡️ Your global filter" : "🛡️ Channel filter", + ) .setDescription(description), ], components: [ @@ -98,8 +100,8 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { .setLabel("Reset") .setStyle(ButtonStyle.Danger) .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), - homeButton(router), ), + navRow(router), ...paginationRows(router, panelPath(scope), { page, pageCount }), ], }; diff --git a/src/routes/get.ts b/src/routes/get.ts index 4d063a1..234365b 100644 --- a/src/routes/get.ts +++ b/src/routes/get.ts @@ -1,49 +1,49 @@ import { row } from "@routes/lib/components"; +import { + buttonEmoji, + emojiIconURL, + RINGVC_EMOJI_ID, + VC_EMOJI_ID, +} from "@routes/lib/emoji"; import { withFlash } from "@routes/lib/flash"; -import { commandMention } from "@routes/lib/mentions"; import { Handler } from "@routes/types"; import { RouteButtonBuilder } from "discord-embed-router"; import { ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; -import { CommandName } from "@commands/commandNames"; - import { COLOR } from "./_shared"; const GITHUB_URL = "https://github.com/altrup/RingVC"; const SUPPORT_URL = "https://discord.gg/bxBePEnndq"; export const homeGet: Handler<"GET"> = (router, interaction, state) => { - const mention = (name: CommandName) => commandMention(state.globals, name); + const vc = buttonEmoji(interaction, VC_EMOJI_ID); + const brandIcon = emojiIconURL(interaction, RINGVC_EMOJI_ID); + + const embed = new EmbedBuilder() + .setColor(COLOR) + .setTitle("🔔 RingVC") + .setDescription( + withFlash( + state.queryParams, + "RingVC replicates group-chat voice calls in Discord servers: sign up for a voice channel and get pinged when someone starts a call there.\n\n" + + "-# Everything the commands do lives in the panels below.", + ), + ); + if (brandIcon) embed.setAuthor({ name: "RingVC", iconURL: brandIcon }); + + // the Signups button carries the branded voice-channel emoji when it + // resolves, falling back to a unicode bell otherwise + const signups = new RouteButtonBuilder(router) + .setStyle(ButtonStyle.Secondary) + .setTo("/signups"); + if (vc) signups.setEmoji(vc).setLabel("Signups"); + else signups.setLabel("🔔 Signups"); return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("RingVC") - .setDescription( - withFlash( - state.queryParams, - "RingVC replicates group-chat voice calls in Discord servers: sign up for a voice channel and get pinged when someone starts a call there.", - ), - ) - .addFields( - { - name: "Quick commands", - value: `${mention("signup")} to get rung for a voice channel, ${mention("ring")} to ring people into yours, and ${mention("block")} to stop someone from ringing you.`, - }, - { - name: "Panels", - value: - "Everything the commands do can also be done from the panels below.", - }, - ), - ], + embeds: [embed], components: [ row( - new RouteButtonBuilder(router) - .setLabel("🔔 Signups") - .setStyle(ButtonStyle.Primary) - .setTo("/signups"), + signups, new RouteButtonBuilder(router) .setLabel("🛡️ Filter") .setStyle(ButtonStyle.Secondary) @@ -56,16 +56,16 @@ export const homeGet: Handler<"GET"> = (router, interaction, state) => { .setLabel("💤 Mode") .setStyle(ButtonStyle.Secondary) .setTo("/mode"), - new RouteButtonBuilder(router) - .setLabel("🗑️ Delete data") - .setStyle(ButtonStyle.Danger) - .setTo("/delete-data"), ), row( new RouteButtonBuilder(router) .setLabel("📖 Commands") .setStyle(ButtonStyle.Secondary) .setTo("/commands"), + new RouteButtonBuilder(router) + .setLabel("🗑️ Delete data") + .setStyle(ButtonStyle.Danger) + .setTo("/delete-data"), new ButtonBuilder() .setLabel("Github") .setStyle(ButtonStyle.Link) diff --git a/src/routes/index.ts b/src/routes/index.ts index 1b3d103..19f6b92 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -24,10 +24,10 @@ import { ringUserPost } from "./ring/user/post"; import { ringUsersPost } from "./ring/users/post"; import { signupsGet } from "./signups/get"; import { signupsMembersPost } from "./signups/members/post"; -import { signupsRoleGet } from "./signups/roles/[roleId]/get"; -import { signupsRolePost } from "./signups/roles/[roleId]/post"; -import { signupsRolesGet } from "./signups/roles/get"; -import { signupsRolesRemovePost } from "./signups/roles/remove/post"; +import { rolesByChannelGet } from "./signups/roles/by-channel/get"; +import { rolesByChannelEditPost } from "./signups/roles/by-channel/roles/post"; +import { rolesByRoleEditPost } from "./signups/roles/by-role/channels/post"; +import { rolesByRoleGet } from "./signups/roles/by-role/get"; // handlers live in files mirroring their route: the folder is the path (with // [param] segments) and the file is the method @@ -63,12 +63,26 @@ export const registerRoutes = (router: RingRouter) => { router.route("/signups", { get: signupsGet }); router.route("/signups/members", { post: signupsMembersPost }); - router.route("/signups/roles", { get: signupsRolesGet }); - router.route("/signups/roles/remove", { post: signupsRolesRemovePost }); - // registered after /signups/roles/remove so "remove" never binds :roleId - router.route("/signups/roles/:roleId", { - get: signupsRoleGet, - post: signupsRolePost, + + // role signups are a scoped panel like filter: an orientation segment + // (by-channel / by-role) then the scope id. The bare path lands on the + // by-channel view with nothing picked yet + router.route( + [ + "/signups/roles", + "/signups/roles/by-channel", + "/signups/roles/by-channel/:scope", + ], + { get: rolesByChannelGet }, + ); + router.route("/signups/roles/by-channel/:scope/roles", { + post: rolesByChannelEditPost, + }); + router.route(["/signups/roles/by-role", "/signups/roles/by-role/:scope"], { + get: rolesByRoleGet, + }); + router.route("/signups/roles/by-role/:scope/channels", { + post: rolesByRoleEditPost, }); router.route("/ring", { get: ringGet }); diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 417f611..d234600 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -30,6 +30,17 @@ export const backButton = ( .setStyle(ButtonStyle.Secondary) .setTo(path); +// the navigation row every panel ends on: Back (when the panel has a parent) +// then Home, always last and on their own row, so navigation sits in the same +// place regardless of a panel's action buttons +export const navRow = ( + router: RingRouter, + parentPath?: string, +): APIActionRowComponent => + parentPath + ? row(backButton(router, parentPath), homeButton(router)) + : row(homeButton(router)); + // the conditional pagination row: absent for single-page lists, so the // result is spread into a components array export const paginationRows = ( diff --git a/src/routes/lib/emoji.ts b/src/routes/lib/emoji.ts new file mode 100644 index 0000000..5828c55 --- /dev/null +++ b/src/routes/lib/emoji.ts @@ -0,0 +1,26 @@ +import { ComponentEmojiResolvable, Interaction } from "discord.js"; + +// branded custom emoji: identity mark and voice-channel action mark +export const RINGVC_EMOJI_ID = "1324809350899961877"; +export const VC_EMOJI_ID = "1326000686575521883"; + +// custom emoji only render where the bot can resolve them; a client that +// doesn't share a guild carrying the emoji would show a broken glyph, so +// callers gate on availability and fall back to no emoji +const hasEmoji = (interaction: Interaction, id: string): boolean => + interaction.client.emojis.cache.has(id); + +export const buttonEmoji = ( + interaction: Interaction, + id: string, +): ComponentEmojiResolvable | undefined => + hasEmoji(interaction, id) ? { id } : undefined; + +// the animated/static emoji image, usable as an embed author or thumbnail icon +export const emojiIconURL = ( + interaction: Interaction, + id: string, +): string | undefined => + hasEmoji(interaction, id) + ? `https://cdn.discordapp.com/emojis/${id}.webp` + : undefined; diff --git a/src/routes/lib/flash.test.ts b/src/routes/lib/flash.test.ts index a78278a..c6dbc0f 100644 --- a/src/routes/lib/flash.test.ts +++ b/src/routes/lib/flash.test.ts @@ -19,13 +19,24 @@ test("flashRedirect merges extra params like the page to stay on", () => { }); }); -test("flashLine renders the flash verbatim with an icon from the level alone", () => { +test("flashLine renders a blockquote with the level icon and a bold lead", () => { expect( flashLine(new URLSearchParams({ flash: "Done", level: "success" })), - ).toBe("✅ Done"); + ).toBe("> ✅ **Done**"); expect( flashLine(new URLSearchParams({ flash: "Careful", level: "warn" })), - ).toBe("⚠️ Careful"); + ).toBe("> ⚠️ **Careful**"); +}); + +test("flashLine bolds only the opening clause through the first period", () => { + expect( + flashLine( + new URLSearchParams({ + flash: "Auto-ring enabled. You'll ring these people.", + level: "success", + }), + ), + ).toBe("> ✅ **Auto-ring enabled.** You'll ring these people."); }); test("flashLine returns null when no flash is present", () => { diff --git a/src/routes/lib/flash.ts b/src/routes/lib/flash.ts index 1df07d4..569c8b0 100644 --- a/src/routes/lib/flash.ts +++ b/src/routes/lib/flash.ts @@ -16,15 +16,29 @@ export const flashRedirect = ( queryParams: { flash, level, ...extraParams }, }); -// the notice line a panel renders at the top of its embed; the flash text is -// shown verbatim, only the level picks the icon +// bolds the flash's opening clause (through the first period, or the whole +// text when it has none) so the outcome reads at a glance +const boldLead = (text: string): string => { + const periodIndex = text.indexOf("."); + if (periodIndex === -1) return `**${text}**`; + return `**${text.slice(0, periodIndex + 1)}**${text.slice(periodIndex + 1)}`; +}; + +// the notice a panel renders at the top of its embed, as a markdown +// blockquote (Discord draws a grey left bar) so it separates from the body; +// the level picks the icon and the opening clause is bolded export const flashLine = (queryParams: URLSearchParams): string | null => { const flash = queryParams.get("flash"); if (!flash) return null; - return `${queryParams.get("level") === "warn" ? "⚠️" : "✅"} ${flash}`; + const icon = queryParams.get("level") === "warn" ? "⚠️" : "✅"; + return `${icon} ${boldLead(flash)}` + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); }; -// an embed description with the flash notice, when present, above the body +// an embed description with the flash notice, when present, above the body, +// always with one blank line between the blockquote and the body export const withFlash = ( queryParams: URLSearchParams, body: string, diff --git a/src/routes/mode/get.ts b/src/routes/mode/get.ts index 6be2ff5..cffcdc2 100644 --- a/src/routes/mode/get.ts +++ b/src/routes/mode/get.ts @@ -1,4 +1,4 @@ -import { homeButton, row } from "@routes/lib/components"; +import { navRow, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { Handler } from "@routes/types"; import { RouteButtonBuilder } from "discord-embed-router"; @@ -8,24 +8,28 @@ import { getUserMode } from "@db/users"; import { MODES, PATH } from "./_shared"; -const COLOR = "#F5853F"; +const COLOR = "#b28b45"; + +// compact one-line legend replacing a field per mode +const LEGEND = + "-# **Normal**: rings everyone · **Stealth**: nobody · **Auto**: stealth while invisible"; export const modeGet: Handler<"GET"> = async (router, interaction, state) => { const current = await getUserMode(interaction.user.id); + const currentLabel = + MODES.find(({ mode }) => mode === current)?.label ?? current; return { embeds: [ new EmbedBuilder() .setColor(COLOR) - .setTitle("Your mode") + .setTitle("💤 Your mode") .setDescription( withFlash( state.queryParams, - `Modes decide what happens when you join a voice channel. Your current mode is **${current}**.`, + "Modes decide what happens when you join a voice channel.\n\n" + + `**Current mode** · ${currentLabel}\n${LEGEND}`, ), - ) - .addFields( - MODES.map(({ label, effect }) => ({ name: label, value: effect })), ), ], components: [ @@ -39,8 +43,8 @@ export const modeGet: Handler<"GET"> = async (router, interaction, state) => { .setDisabled(mode === current) .setTo(PATH, { method: "POST", queryParams: { set: mode } }), ), - homeButton(router), ), + navRow(router), ], }; }; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 0a966c8..5929ff9 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -1,4 +1,4 @@ -import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { navRow, paginationRows, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { PAGE_SIZE, paginate } from "@routes/lib/paging"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; @@ -21,7 +21,7 @@ import { mentionUser } from "@main/ring"; import { panelPath } from "../_shared"; -const COLOR = "#747ac5"; +const COLOR = "#8c89cd"; // the channel override when in channel scope, the global setting, and the // value that actually applies for the scope @@ -57,20 +57,22 @@ export const recipientsGet: Handler<"GET"> = async ( state.queryParams.get("page"), ); - const autoRingLine = + const autoRingValue = channelId === null - ? `Auto-ring: **${autoRing.effective ? "enabled" : "disabled"}**` + ? autoRing.effective + ? "On" + : "Off" : autoRing.override !== undefined - ? `Auto-ring: **${autoRing.override ? "enabled" : "disabled"}** for <#${channelId}> (overrides your global setting: ${autoRing.global ? "enabled" : "disabled"})` - : `Auto-ring: **${autoRing.effective ? "enabled" : "disabled"}** (from your global setting)`; + ? `${autoRing.override ? "On" : "Off"} for <#${channelId}> (global: ${autoRing.global ? "on" : "off"})` + : `${autoRing.effective ? "On" : "Off"} (from your global setting)`; const memberList = pageItems.length > 0 ? pageItems.map(mentionUser).join(" ") : "None"; const description = withFlash( state.queryParams, - `These people get rung when you use Ring defaults${channelId ? ` in <#${channelId}>` : ""}, or on every voice channel join if auto-ring is enabled.\n\n` + - `${autoRingLine}\n\n` + - `**Recipients${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:** ${memberList}`, + `These people get rung when you use Ring defaults${channelId ? ` in <#${channelId}>` : ""}, or on every voice channel join if auto-ring is on.\n\n` + + `**Auto-ring** · ${autoRingValue}\n` + + `**Recipients${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${memberList}`, ); return { @@ -79,8 +81,8 @@ export const recipientsGet: Handler<"GET"> = async ( .setColor(COLOR) .setTitle( scope === "global" - ? "Your default ring recipients" - : "Channel ring recipients", + ? "📣 Default ring recipients" + : "📣 Channel ring recipients", ) .setDescription(description), ], @@ -116,10 +118,6 @@ export const recipientsGet: Handler<"GET"> = async ( ) .toJSON(), row( - new RouteButtonBuilder(router) - .setLabel("Reset") - .setStyle(ButtonStyle.Danger) - .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), new RouteButtonBuilder(router) .setLabel( autoRing.effective ? "Disable auto-ring" : "Enable auto-ring", @@ -131,6 +129,10 @@ export const recipientsGet: Handler<"GET"> = async ( method: "POST", queryParams: { enable: autoRing.effective ? "0" : "1" }, }), + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Danger) + .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), ...(channelId !== null && autoRing.override !== undefined ? [ new RouteButtonBuilder(router) @@ -141,8 +143,8 @@ export const recipientsGet: Handler<"GET"> = async ( }), ] : []), - homeButton(router), ), + navRow(router), ...paginationRows(router, panelPath(scope), { page, pageCount }), ], }; diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index 8ee6bab..c608f0e 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -1,4 +1,4 @@ -import { homeButton, row } from "@routes/lib/components"; +import { homeButton, navRow, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { PAGE_SIZE } from "@routes/lib/paging"; import { Handler } from "@routes/types"; @@ -13,7 +13,7 @@ import { joinWithAnd, mentionUser } from "@main/ring"; import { NOT_IN_VC, PANEL, voiceChannelOf } from "./_shared"; -const COLOR = "#c58a74"; +const COLOR = "#c87a6d"; export const ringGet: Handler<"GET"> = async (router, interaction, state) => { const channel = voiceChannelOf(interaction); @@ -32,16 +32,14 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { const description = withFlash( state.queryParams, `Ringing people into <#${channel.id}>.\n\n` + - (defaults.length > 0 - ? `Your default recipients here: ${joinWithAnd(defaults.map(mentionUser))}` - : "You have no default recipients here."), + `**Your defaults here** · ${defaults.length > 0 ? joinWithAnd(defaults.map(mentionUser)) : "None"}`, ); return { embeds: [ new EmbedBuilder() .setColor(COLOR) - .setTitle("Ring") + .setTitle("📣 Ring") .setDescription(description), ], components: [ @@ -59,8 +57,8 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { .setLabel("Ring defaults") .setStyle(ButtonStyle.Success) .setTo(`${PANEL}/default`, { method: "POST" }), - homeButton(router), ), + navRow(router), ], }; }; diff --git a/src/routes/signups/_shared.ts b/src/routes/signups/_shared.ts index c42f5c2..3d3ab9f 100644 --- a/src/routes/signups/_shared.ts +++ b/src/routes/signups/_shared.ts @@ -12,7 +12,7 @@ import { getVoiceChatRoleSignups, } from "@db/voice-chats"; -export const COLOR = "#62a8ab"; +export const COLOR = "#31a5a5"; export const PANEL = "/signups"; export const ROLES = "/signups/roles"; diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index 352fc90..de75ea8 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -1,4 +1,9 @@ -import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { + homeButton, + navRow, + paginationRows, + row, +} from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { PAGE_SIZE, paginate } from "@routes/lib/paging"; import { Handler } from "@routes/types"; @@ -42,14 +47,14 @@ export const signupsGet: Handler<"GET"> = async ( const description = withFlash( state.queryParams, "You get rung when someone starts a call in one of these channels.\n\n" + - `**Your signups in this server${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:** ${channelList}`, + `**Signups${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${channelList}`, ); return { embeds: [ new EmbedBuilder() .setColor(COLOR) - .setTitle("Your signups") + .setTitle("🔔 Your signups") .setDescription(description), ], components: [ @@ -72,8 +77,8 @@ export const signupsGet: Handler<"GET"> = async ( .setLabel("Role signups") .setStyle(ButtonStyle.Secondary) .setTo(ROLES), - homeButton(router), ), + navRow(router), ...paginationRows(router, PANEL, { page, pageCount }), ], }; diff --git a/src/routes/signups/roles/[roleId]/get.ts b/src/routes/signups/roles/[roleId]/get.ts deleted file mode 100644 index e4026ad..0000000 --- a/src/routes/signups/roles/[roleId]/get.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { backButton, homeButton, row } from "@routes/lib/components"; -import { withFlash } from "@routes/lib/flash"; -import { Handler } from "@routes/types"; -import { RouteChannelSelectMenuBuilder } from "discord-embed-router"; -import { ActionRowBuilder, ChannelType, EmbedBuilder } from "discord.js"; - -import { mentionRole } from "@main/ring"; - -import { - canManageRoleSignups, - COLOR, - guildOnlyRender, - noPermissionRender, - ROLES, -} from "../../_shared"; - -export const signupsRoleGet: Handler<"GET"> = async ( - router, - interaction, - state, -) => { - const guild = interaction.guild; - if (!guild) - return { ...guildOnlyRender, components: [row(homeButton(router))] }; - if (!canManageRoleSignups(interaction)) return noPermissionRender(router); - - const roleId = - typeof state.params.roleId === "string" ? state.params.roleId : ""; - - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("Add a role signup") - .setDescription( - withFlash( - state.queryParams, - `Signing up ${mentionRole(roleId)}: pick the voice channel it should be pinged for.`, - ), - ), - ], - components: [ - new ActionRowBuilder() - .addComponents( - new RouteChannelSelectMenuBuilder(router) - .setChannelTypes(ChannelType.GuildVoice) - .setMinValues(1) - .setMaxValues(1) - .setPlaceholder(`Pick a channel for the role`) - .setPattern(`${ROLES}/${roleId}`, { - method: "POST", - queryParams: { channel: ":channelId" }, - }), - ) - .toJSON(), - row(backButton(router, ROLES, "Cancel"), homeButton(router)), - ], - }; -}; diff --git a/src/routes/signups/roles/[roleId]/post.ts b/src/routes/signups/roles/[roleId]/post.ts deleted file mode 100644 index dd07f9c..0000000 --- a/src/routes/signups/roles/[roleId]/post.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { flashRedirect } from "@routes/lib/flash"; -import { Handler } from "@routes/types"; - -import { addVoiceChatRole } from "@db/voice-chats"; -import { mentionRole } from "@main/ring"; - -import { canManageRoleSignups, mentionChannel, ROLES } from "../../_shared"; - -export const signupsRolePost: Handler<"POST"> = async ( - router, - interaction, - state, -) => { - if (!canManageRoleSignups(interaction)) - return flashRedirect( - ROLES, - "You need the Manage Roles permission to manage role signups", - "warn", - ); - - const roleId = - typeof state.params.roleId === "string" ? state.params.roleId : ""; - const channelId = state.queryParams.get("channel") ?? ""; - const channel = interaction.guild?.channels.cache.get(channelId); - if (!channel?.isVoiceBased()) - return flashRedirect( - `${ROLES}/${roleId}`, - "Pick a voice channel to finish the role signup", - "warn", - ); - - const added = await addVoiceChatRole(channelId, roleId); - return added - ? flashRedirect( - ROLES, - `Signed up ${mentionRole(roleId)} for ${mentionChannel(channelId)}`, - "success", - ) - : flashRedirect( - ROLES, - `${mentionRole(roleId)} is already signed up for ${mentionChannel(channelId)}`, - "warn", - ); -}; diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts new file mode 100644 index 0000000..d7b1c55 --- /dev/null +++ b/src/routes/signups/roles/_shared.ts @@ -0,0 +1,178 @@ +import { navRow, paginationRows, row } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { Page } from "@routes/lib/paging"; +import { RingRouter } from "@routes/types"; +import { RouteButtonBuilder, RouteRedirect } from "discord-embed-router"; +import { + APIActionRowComponent, + APIComponentInMessageActionRow, + ButtonStyle, + EmbedBuilder, + Guild, +} from "discord.js"; + +import { joinWithAnd } from "@main/ring"; + +import { COLOR, PANEL } from "../_shared"; + +export const BY_CHANNEL = "/signups/roles/by-channel"; +export const BY_ROLE = "/signups/roles/by-role"; + +export type Orientation = "channel" | "role"; + +// the shared lead sentence; both orientations describe the same feature +const LEAD = + "When someone joins a voice channel, its signed-up roles get pinged."; + +// the orientation toggle: the active view is an inert Primary, the other a +// Secondary link that resets the scope (a channel id is not a role id) +const switchRow = ( + router: RingRouter, + active: Orientation, +): APIActionRowComponent => + row( + new RouteButtonBuilder(router) + .setLabel("By channel") + .setStyle( + active === "channel" ? ButtonStyle.Primary : ButtonStyle.Secondary, + ) + .setDisabled(active === "channel") + .setTo(BY_CHANNEL), + new RouteButtonBuilder(router) + .setLabel("By role") + .setStyle(active === "role" ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setDisabled(active === "role") + .setTo(BY_ROLE), + ); + +// the scope carried in the path, or null when nothing is picked yet (unlike +// filter/recipients, role signups have no global scope to fall back to) +export const roleScopeOf = ( + params: Partial>, +): string | null => + typeof params.scope === "string" && params.scope.length > 0 + ? params.scope + : null; + +// stable name ordering so a scope's links paginate the same way on the GET +// that renders the select and the POST that diffs its submission +export const sortRoleIds = (guild: Guild, ids: string[]): string[] => + [...ids].sort((a, b) => + (guild.roles.cache.get(a)?.name ?? a).localeCompare( + guild.roles.cache.get(b)?.name ?? b, + ), + ); + +export const sortChannelIds = (guild: Guild, ids: string[]): string[] => + [...ids].sort((a, b) => + (guild.channels.cache.get(a)?.name ?? a).localeCompare( + guild.channels.cache.get(b)?.name ?? b, + ), + ); + +// applies an add/remove edit against a scope's current links and reports the +// outcome as a flash. Adds/removes are filtered against the current set so +// the notice reflects real changes; both edit orientations mutate the same +// (channel, role) table through the passed callbacks +export const commitRoleEdit = async ({ + redirect, + page, + current, + addsRequested, + removesRequested, + mutateAdd, + mutateRemove, + itemMention, +}: { + redirect: string; + page: number; + current: string[]; + addsRequested: string[]; + removesRequested: string[]; + mutateAdd: (item: string) => Promise; + mutateRemove: (item: string) => Promise; + itemMention: (id: string) => string; +}): Promise => { + const currentSet = new Set(current); + const toAdd = addsRequested.filter((item) => !currentSet.has(item)); + const toRemove = removesRequested.filter((item) => currentSet.has(item)); + await Promise.all([...toAdd.map(mutateAdd), ...toRemove.map(mutateRemove)]); + + const parts = [ + ...(toAdd.length > 0 + ? [`Added ${joinWithAnd(toAdd.map(itemMention))}`] + : []), + ...(toRemove.length > 0 + ? [`Removed ${joinWithAnd(toRemove.map(itemMention))}`] + : []), + ]; + const changed = toAdd.length > 0 || toRemove.length > 0; + const flash = changed + ? parts.join(". ") + : addsRequested.length > 0 + ? "Already signed up." + : removesRequested.length > 0 + ? "Those weren't signed up." + : "No changes to role signups."; + return flashRedirect(redirect, flash, changed ? "success" : "warn", { + page: String(page), + }); +}; + +// assembles either orientation from its orientation-specific pieces: the +// caller builds the scope and edit selects (their menu types differ) and +// supplies the scope's current links; the frame, copy, and rows are shared +export const renderRoleScope = ({ + router, + queryParams, + active, + scope, + scopeMention, + linkedLabel, + linkedItems, + itemMention, + emptyHint, + scopeSelectRow, + editSelectRow, + basePath, + page, + pageCount, +}: { + router: RingRouter; + queryParams: URLSearchParams; + active: Orientation; + scope: string | null; + scopeMention: (id: string) => string; + linkedLabel: string; + linkedItems: string[]; + itemMention: (id: string) => string; + emptyHint: string; + scopeSelectRow: APIActionRowComponent; + // present only when a scope is picked; the empty view shows no edit select + editSelectRow?: + APIActionRowComponent | undefined; + basePath: string; +} & Pick) => { + const linkedList = + linkedItems.length > 0 ? linkedItems.map(itemMention).join(" ") : "None"; + const body = scope + ? `${LEAD}\n\n**Viewing** ${scopeMention(scope)}\n` + + `**${linkedLabel}${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${linkedList}` + : `${LEAD}\n\n${emptyHint}`; + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("🔔 Role signups") + .setDescription(withFlash(queryParams, body)), + ], + components: [ + switchRow(router, active), + scopeSelectRow, + ...(scope && editSelectRow ? [editSelectRow] : []), + navRow(router, PANEL), + ...paginationRows(router, basePath, { page, pageCount }), + ], + }; +}; diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts new file mode 100644 index 0000000..56fe1da --- /dev/null +++ b/src/routes/signups/roles/by-channel/get.ts @@ -0,0 +1,93 @@ +import { homeButton, row } from "@routes/lib/components"; +import { PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; +import { + RouteChannelSelectMenuBuilder, + RouteRoleSelectMenuBuilder, +} from "discord-embed-router"; +import { ActionRowBuilder, ChannelType } from "discord.js"; + +import { getVoiceChatSignups } from "@db/voice-chats"; +import { mentionRole } from "@main/ring"; + +import { + BY_CHANNEL, + renderRoleScope, + roleScopeOf, + sortRoleIds, +} from "../_shared"; +import { + canManageRoleSignups, + guildOnlyRender, + mentionChannel, + noPermissionRender, +} from "../../_shared"; + +export const rolesByChannelGet: Handler<"GET"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!canManageRoleSignups(interaction)) return noPermissionRender(router); + + const scope = roleScopeOf(state.params); + const roleIds = scope + ? sortRoleIds(guild, (await getVoiceChatSignups(scope)).roleIds) + : []; + const { pageItems, page, pageCount } = paginate( + roleIds, + state.queryParams.get("page"), + ); + + const scopeSelectRow = new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder( + scope + ? "Viewing a channel's roles (clear to pick another)" + : "View a voice channel's roles", + ) + .setDefaultChannels(...(scope ? [scope] : [])) + .setPattern(`${BY_CHANNEL}{/:channelId}`), + ) + .toJSON(); + + const editSelectRow = scope + ? new ActionRowBuilder() + .addComponents( + new RouteRoleSelectMenuBuilder(router) + .setMinValues(0) + .setMaxValues(PAGE_SIZE) + .setPlaceholder("Edit roles: select to add, deselect to remove") + .setDefaultRoles(...pageItems) + .setPattern(`${BY_CHANNEL}/${scope}/roles`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON() + : undefined; + + return renderRoleScope({ + router, + queryParams: state.queryParams, + active: "channel", + scope, + scopeMention: mentionChannel, + linkedLabel: "Roles pinged here", + linkedItems: pageItems, + itemMention: mentionRole, + emptyHint: "Pick a channel above to view or edit its roles.", + scopeSelectRow, + editSelectRow, + basePath: `${BY_CHANNEL}/${scope}`, + page, + pageCount, + }); +}; diff --git a/src/routes/signups/roles/by-channel/roles/post.ts b/src/routes/signups/roles/by-channel/roles/post.ts new file mode 100644 index 0000000..759643e --- /dev/null +++ b/src/routes/signups/roles/by-channel/roles/post.ts @@ -0,0 +1,75 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { diffSelection, paginate } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; + +import { + addVoiceChatRole, + getVoiceChatSignups, + removeVoiceChatRole, +} from "@db/voice-chats"; +import { mentionRole } from "@main/ring"; + +import { + BY_CHANNEL, + commitRoleEdit, + roleScopeOf, + sortRoleIds, +} from "../../_shared"; +import { canManageRoleSignups } from "../../../_shared"; + +// edits the roles signed up to one voice channel. Serves the panel's role +// multi-select (diffed against the shown page) and the /signuprole adapter +// (an `add` query param); the scope is the channel id in the path +export const rolesByChannelEditPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return flashRedirect( + BY_CHANNEL, + "Signups only work inside a Discord server", + "warn", + ); + if (!canManageRoleSignups(interaction)) + return flashRedirect( + BY_CHANNEL, + "You need the Manage Roles permission to manage role signups", + "warn", + ); + + const scope = roleScopeOf(state.params); + if (!scope) return flashRedirect(BY_CHANNEL, "Pick a channel first", "warn"); + + const query = state.queryParams; + const current = sortRoleIds( + guild, + (await getVoiceChatSignups(scope)).roleIds, + ); + + let addsRequested: string[]; + let removesRequested: string[]; + if (state.values) { + const { pageItems } = paginate(current, query.get("page")); + ({ added: addsRequested, removed: removesRequested } = diffSelection({ + allItems: current, + pageItems, + submitted: state.values, + })); + } else { + addsRequested = query.getAll("add"); + removesRequested = query.getAll("remove"); + } + + return commitRoleEdit({ + redirect: `${BY_CHANNEL}/${scope}`, + page: parseInt(query.get("page") ?? "0") || 0, + current, + addsRequested, + removesRequested, + mutateAdd: (roleId) => addVoiceChatRole(scope, roleId), + mutateRemove: (roleId) => removeVoiceChatRole(scope, roleId), + itemMention: mentionRole, + }); +}; diff --git a/src/routes/signups/roles/by-role/channels/post.ts b/src/routes/signups/roles/by-role/channels/post.ts new file mode 100644 index 0000000..03c7685 --- /dev/null +++ b/src/routes/signups/roles/by-role/channels/post.ts @@ -0,0 +1,84 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { diffSelection, paginate } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; + +import { + addVoiceChatRole, + getVoiceChatRoleSignups, + removeVoiceChatRole, +} from "@db/voice-chats"; + +import { + BY_ROLE, + commitRoleEdit, + roleScopeOf, + sortChannelIds, +} from "../../_shared"; +import { + canManageRoleSignups, + guildVoiceChannelIds, + mentionChannel, +} from "../../../_shared"; + +// edits the channels one role is signed up to. Serves the panel's channel +// multi-select (diffed against the shown page) and the /unsignuprole adapter +// (a `remove` query param, or `removeAll` to clear every channel); the scope +// is the role id in the path +export const rolesByRoleEditPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return flashRedirect( + BY_ROLE, + "Signups only work inside a Discord server", + "warn", + ); + if (!canManageRoleSignups(interaction)) + return flashRedirect( + BY_ROLE, + "You need the Manage Roles permission to manage role signups", + "warn", + ); + + const scope = roleScopeOf(state.params); + if (!scope) return flashRedirect(BY_ROLE, "Pick a role first", "warn"); + + const query = state.queryParams; + const current = sortChannelIds( + guild, + (await getVoiceChatRoleSignups(guildVoiceChannelIds(guild))) + .filter((mapping) => mapping.roleId === scope) + .map((mapping) => mapping.channelId), + ); + + let addsRequested: string[]; + let removesRequested: string[]; + if (state.values) { + const { pageItems } = paginate(current, query.get("page")); + ({ added: addsRequested, removed: removesRequested } = diffSelection({ + allItems: current, + pageItems, + submitted: state.values, + })); + } else if (query.get("removeAll")) { + addsRequested = []; + removesRequested = current; + } else { + addsRequested = query.getAll("add"); + removesRequested = query.getAll("remove"); + } + + return commitRoleEdit({ + redirect: `${BY_ROLE}/${scope}`, + page: parseInt(query.get("page") ?? "0") || 0, + current, + addsRequested, + removesRequested, + mutateAdd: (channelId) => addVoiceChatRole(channelId, scope), + mutateRemove: (channelId) => removeVoiceChatRole(channelId, scope), + itemMention: mentionChannel, + }); +}; diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts new file mode 100644 index 0000000..039ec75 --- /dev/null +++ b/src/routes/signups/roles/by-role/get.ts @@ -0,0 +1,99 @@ +import { homeButton, row } from "@routes/lib/components"; +import { PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; +import { + RouteChannelSelectMenuBuilder, + RouteRoleSelectMenuBuilder, +} from "discord-embed-router"; +import { ActionRowBuilder, ChannelType } from "discord.js"; + +import { getVoiceChatRoleSignups } from "@db/voice-chats"; +import { mentionRole } from "@main/ring"; + +import { + BY_ROLE, + renderRoleScope, + roleScopeOf, + sortChannelIds, +} from "../_shared"; +import { + canManageRoleSignups, + guildOnlyRender, + guildVoiceChannelIds, + mentionChannel, + noPermissionRender, +} from "../../_shared"; + +export const rolesByRoleGet: Handler<"GET"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!canManageRoleSignups(interaction)) return noPermissionRender(router); + + const scope = roleScopeOf(state.params); + const channelIds = scope + ? sortChannelIds( + guild, + (await getVoiceChatRoleSignups(guildVoiceChannelIds(guild))) + .filter((mapping) => mapping.roleId === scope) + .map((mapping) => mapping.channelId), + ) + : []; + const { pageItems, page, pageCount } = paginate( + channelIds, + state.queryParams.get("page"), + ); + + const scopeSelectRow = new ActionRowBuilder() + .addComponents( + new RouteRoleSelectMenuBuilder(router) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder( + scope + ? "Viewing a role's channels (clear to pick another)" + : "View a role's channels", + ) + .setDefaultRoles(...(scope ? [scope] : [])) + .setPattern(`${BY_ROLE}{/:roleId}`), + ) + .toJSON(); + + const editSelectRow = scope + ? new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(PAGE_SIZE) + .setPlaceholder("Edit channels: select to add, deselect to remove") + .setDefaultChannels(...pageItems) + .setPattern(`${BY_ROLE}/${scope}/channels`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON() + : undefined; + + return renderRoleScope({ + router, + queryParams: state.queryParams, + active: "role", + scope, + scopeMention: mentionRole, + linkedLabel: "Signed up for", + linkedItems: pageItems, + itemMention: mentionChannel, + emptyHint: "Pick a role above to view or edit its channels.", + scopeSelectRow, + editSelectRow, + basePath: `${BY_ROLE}/${scope}`, + page, + pageCount, + }); +}; diff --git a/src/routes/signups/roles/get.ts b/src/routes/signups/roles/get.ts deleted file mode 100644 index 76cc088..0000000 --- a/src/routes/signups/roles/get.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { - backButton, - homeButton, - paginationRows, - row, -} from "@routes/lib/components"; -import { withFlash } from "@routes/lib/flash"; -import { paginate } from "@routes/lib/paging"; -import { Handler } from "@routes/types"; -import { - RouteRoleSelectMenuBuilder, - RouteStringSelectMenuBuilder, - RouteStringSelectMenuOptionBuilder, -} from "discord-embed-router"; -import { ActionRowBuilder, EmbedBuilder } from "discord.js"; - -import { mentionRole } from "@main/ring"; - -import { - canManageRoleSignups, - COLOR, - guildOnlyRender, - mentionChannel, - noPermissionRender, - PANEL, - ROLES, - sortedRoleSignups, -} from "../_shared"; - -export const signupsRolesGet: Handler<"GET"> = async ( - router, - interaction, - state, -) => { - const guild = interaction.guild; - if (!guild) - return { ...guildOnlyRender, components: [row(homeButton(router))] }; - if (!canManageRoleSignups(interaction)) return noPermissionRender(router); - - const mappings = await sortedRoleSignups(guild); - const pageStrings = mappings.map( - ({ roleId, channelId }) => `${roleId}:${channelId}`, - ); - const { pageItems, page, pageCount } = paginate( - pageStrings, - state.queryParams.get("page"), - ); - - const mappingLines = - pageItems.length > 0 - ? pageItems - .map((pair) => { - const [roleId = "", channelId = ""] = pair.split(":"); - return `${mentionRole(roleId)} → ${mentionChannel(channelId)}`; - }) - .join("\n") - : "None yet"; - const description = withFlash( - state.queryParams, - "When someone joins a channel, its signed-up roles get pinged.\n\n" + - `**Role signups${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}:**\n${mappingLines}`, - ); - - const removeOptions = pageItems.map((pair) => { - const [roleId = "", channelId = ""] = pair.split(":"); - return new RouteStringSelectMenuOptionBuilder(router) - .setLabel( - `@${guild.roles.cache.get(roleId)?.name ?? roleId} → #${guild.channels.cache.get(channelId)?.name ?? channelId}`, - ) - .setTo(`${ROLES}/remove`, { queryParams: { pair } }); - }); - - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("Role signups") - .setDescription(description), - ], - components: [ - ...(removeOptions.length > 0 - ? [ - new ActionRowBuilder() - .addComponents( - new RouteStringSelectMenuBuilder(router) - .setTos(...removeOptions) - .setPlaceholder("Remove role signups") - .setMinValues(1) - .setMaxValues(removeOptions.length) - .setPattern(`${ROLES}/remove`, { - method: "POST", - queryParams: { page: String(page) }, - }), - ) - .toJSON(), - ] - : []), - new ActionRowBuilder() - .addComponents( - new RouteRoleSelectMenuBuilder(router) - .setMinValues(1) - .setMaxValues(1) - .setPlaceholder("Add a role signup: pick a role") - .setPattern(`${ROLES}/:roleId`), - ) - .toJSON(), - row(backButton(router, PANEL), homeButton(router)), - ...paginationRows(router, ROLES, { page, pageCount }), - ], - }; -}; diff --git a/src/routes/signups/roles/remove/post.ts b/src/routes/signups/roles/remove/post.ts deleted file mode 100644 index 6944a13..0000000 --- a/src/routes/signups/roles/remove/post.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { flashRedirect } from "@routes/lib/flash"; -import { Handler } from "@routes/types"; - -import { removeVoiceChatRole } from "@db/voice-chats"; -import { joinWithAnd, mentionRole } from "@main/ring"; - -import { - canManageRoleSignups, - mentionChannel, - ROLES, - sortedRoleSignups, -} from "../../_shared"; - -// a remove-select value decodes to "?pair=:" -const pairOf = ( - value: string, -): { roleId: string; channelId: string } | null => { - const [, query = ""] = value.split("?"); - const [roleId, channelId] = - new URLSearchParams(query).get("pair")?.split(":") ?? []; - return roleId && channelId ? { roleId, channelId } : null; -}; - -// serves the panel's remove select and the /unsignuprole adapter (role and -// optional channel as query params; no channel removes the role everywhere) -export const signupsRolesRemovePost: Handler<"POST"> = async ( - router, - interaction, - state, -) => { - const guild = interaction.guild; - if (!guild) - return flashRedirect( - ROLES, - "Signups only work inside a Discord server", - "warn", - ); - if (!canManageRoleSignups(interaction)) - return flashRedirect( - ROLES, - "You need the Manage Roles permission to manage role signups", - "warn", - ); - - const query = state.queryParams; - let pairs: { roleId: string; channelId: string }[]; - if (state.values) { - pairs = state.values - .map(pairOf) - .filter((pair): pair is NonNullable => pair !== null); - } else { - const roleId = query.get("role") ?? ""; - const channelId = query.get("channel"); - pairs = channelId - ? [{ roleId, channelId }] - : (await sortedRoleSignups(guild)).filter( - (mapping) => mapping.roleId === roleId, - ); - if (pairs.length === 0) - return flashRedirect( - ROLES, - `${mentionRole(roleId)} isn't signed up for any voice channel`, - "warn", - ); - } - - const removed: typeof pairs = []; - const missing: typeof pairs = []; - for (const pair of pairs) { - if (await removeVoiceChatRole(pair.channelId, pair.roleId)) - removed.push(pair); - else missing.push(pair); - } - - const describe = (list: typeof pairs) => - joinWithAnd( - list.map( - ({ roleId, channelId }) => - `${mentionRole(roleId)} from ${mentionChannel(channelId)}`, - ), - ); - const parts = [ - ...(removed.length > 0 ? [`Removed ${describe(removed)}`] : []), - ...(missing.length > 0 ? [`Already removed: ${describe(missing)}`] : []), - ]; - return flashRedirect( - ROLES, - parts.length > 0 ? parts.join(". ") : "No role signups to remove", - removed.length > 0 ? "success" : "warn", - { page: query.get("page") ?? "0" }, - ); -}; diff --git a/src/routes/signups/signups.test.ts b/src/routes/signups/signups.test.ts index d2e5560..f92cef4 100644 --- a/src/routes/signups/signups.test.ts +++ b/src/routes/signups/signups.test.ts @@ -1,12 +1,19 @@ import { Interaction, PermissionsBitField } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; -import { addVoiceChatRole } from "@db/voice-chats"; +import { + addVoiceChatRole, + getVoiceChatRoleSignups, + getVoiceChatSignups, + removeVoiceChatRole, +} from "@db/voice-chats"; -import { signupsRolePost } from "./roles/[roleId]/post"; +import { rolesByChannelEditPost } from "./roles/by-channel/roles/post"; +import { rolesByRoleEditPost } from "./roles/by-role/channels/post"; vi.mock("@db/voice-chats", () => ({ getUserVoiceChatSignups: vi.fn(), + getVoiceChatSignups: vi.fn(), getVoiceChatRoleSignups: vi.fn(), addVoiceChatUser: vi.fn(), removeVoiceChatUser: vi.fn(), @@ -14,73 +21,118 @@ vi.mock("@db/voice-chats", () => ({ removeVoiceChatRole: vi.fn(), })); -const voiceChannel = { id: "vc1", isVoiceBased: () => true }; -const makeInteraction = (admin: boolean) => +const makeInteraction = (managesRoles: boolean) => ({ user: { id: "caller" }, memberPermissions: new PermissionsBitField( - admin ? PermissionsBitField.Flags.ManageRoles : 0n, + managesRoles ? PermissionsBitField.Flags.ManageRoles : 0n, ), - guild: { channels: { cache: new Map([[voiceChannel.id, voiceChannel]]) } }, + guild: { + roles: { cache: new Map() }, + channels: { + cache: new Map([["vc1", { id: "vc1", isVoiceBased: () => true }]]), + }, + }, }) as unknown as Interaction; -const commitState = (query: string) => +const editState = (opts: { + scope: string; + query?: string; + values?: string[]; +}) => ({ - params: { roleId: "role1" }, - path: "/signups/roles/role1", - queryParams: new URLSearchParams(query), + params: { scope: opts.scope }, + queryParams: new URLSearchParams(opts.query ?? ""), + values: opts.values, timestamp: 0, - }) as unknown as Parameters[2]; + }) as unknown as Parameters[2]; + +const flashOf = (result: { queryParams?: unknown }) => + new URLSearchParams(result.queryParams as Record); beforeEach(() => { vi.clearAllMocks(); + vi.mocked(addVoiceChatRole).mockResolvedValue(true); + vi.mocked(removeVoiceChatRole).mockResolvedValue(true); }); -test("committing a role signup that already exists rejects with a flash", async () => { - vi.mocked(addVoiceChatRole).mockResolvedValue(false); +test("editing a channel's roles adds the newly selected role only", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: [], + roleIds: ["r1"], + }); - const result = await signupsRolePost( + const result = await rolesByChannelEditPost( undefined as never, makeInteraction(true), - commitState("channel=vc1"), + editState({ scope: "vc1", values: ["r1", "r2"] }), ); - expect(result.redirect).toBe("/signups/roles"); - const flashParams = new URLSearchParams( - result.queryParams as Record, - ); - expect(flashParams.get("level")).toBe("warn"); - expect(flashParams.get("flash")).toContain("already"); + expect(addVoiceChatRole).toHaveBeenCalledExactlyOnceWith("vc1", "r2"); + expect(removeVoiceChatRole).not.toHaveBeenCalled(); + expect(result.redirect).toBe("/signups/roles/by-channel/vc1"); + expect(flashOf(result).get("level")).toBe("success"); }); -test("committing a role signup succeeds and reports the mapping", async () => { - vi.mocked(addVoiceChatRole).mockResolvedValue(true); +test("deselecting a role removes it from the channel", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: [], + roleIds: ["r1", "r2"], + }); - const result = await signupsRolePost( + await rolesByChannelEditPost( undefined as never, makeInteraction(true), - commitState("channel=vc1"), + editState({ scope: "vc1", values: ["r1"] }), ); - expect(addVoiceChatRole).toHaveBeenCalledExactlyOnceWith("vc1", "role1"); - expect(result.redirect).toBe("/signups/roles"); - const flashParams = new URLSearchParams( - result.queryParams as Record, + expect(removeVoiceChatRole).toHaveBeenCalledExactlyOnceWith("vc1", "r2"); + expect(addVoiceChatRole).not.toHaveBeenCalled(); +}); + +test("the signuprole add param signs a role up for the channel", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: [], + roleIds: [], + }); + + const result = await rolesByChannelEditPost( + undefined as never, + makeInteraction(true), + editState({ scope: "vc1", query: "add=r5" }), ); - expect(flashParams.get("level")).toBe("success"); + + expect(addVoiceChatRole).toHaveBeenCalledExactlyOnceWith("vc1", "r5"); + expect(result.redirect).toBe("/signups/roles/by-channel/vc1"); }); -test("a role signup commit without Manage Roles mutates nothing", async () => { - const result = await signupsRolePost( +test("a role edit without Manage Roles mutates nothing", async () => { + const result = await rolesByChannelEditPost( undefined as never, makeInteraction(false), - commitState("channel=vc1"), + editState({ scope: "vc1", values: ["r1", "r2"] }), ); expect(addVoiceChatRole).not.toHaveBeenCalled(); - const flashParams = new URLSearchParams( - result.queryParams as Record, + expect(flashOf(result).get("level")).toBe("warn"); + expect(flashOf(result).get("flash")).toContain("Manage Roles"); +}); + +test("unsignuprole removeAll clears every channel the role is signed up to", async () => { + vi.mocked(getVoiceChatRoleSignups).mockResolvedValue([ + { roleId: "role1", channelId: "vcA" }, + { roleId: "role1", channelId: "vcB" }, + { roleId: "other", channelId: "vcC" }, + ]); + + const result = await rolesByRoleEditPost( + undefined as never, + makeInteraction(true), + editState({ scope: "role1", query: "removeAll=1" }), ); - expect(flashParams.get("level")).toBe("warn"); - expect(flashParams.get("flash")).toContain("Manage Roles"); + + expect(removeVoiceChatRole).toHaveBeenCalledWith("vcA", "role1"); + expect(removeVoiceChatRole).toHaveBeenCalledWith("vcB", "role1"); + expect(removeVoiceChatRole).not.toHaveBeenCalledWith("vcC", "role1"); + expect(result.redirect).toBe("/signups/roles/by-role/role1"); }); From d7d14962eb2bbc2a3c95f8de06f444ac078a105f Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:52:36 -0400 Subject: [PATCH 20/82] vc icon --- vc.webp | Bin 0 -> 1554 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 vc.webp diff --git a/vc.webp b/vc.webp new file mode 100644 index 0000000000000000000000000000000000000000..5d24bce648766598b34ef1fd08933221b4a961f9 GIT binary patch literal 1554 zcmV+t2JQJ$Nk&Er1^@t8MM6+kP&iEe1pojqf50CQkLEa%BuB~FLu>qZ27mZ!*xPmk zawJKPn_TgO|Ng)n`PBef5F<%a9A%4{{5MAau=I=#yOAVCmNwPLj~V@U;aTvUcwfv6 zd%dGpUPL?TZpS0{>xt+6jo;^+{@XMEebaxRG5+tQE>qW2!7ua8>CXn=j)v3GV9pOC zdkg`j&C*CE@1*#VMzTW#FoZ#?VIS~62K0}P=@c}X0+I{>jV(ZX{s1852ml!YKr_Px zkN^NABLD~mfCQjZfFQv?v`7yjZQ%nl43J?0XoIlW1K0qxXkw=%000Dlz#2f%gb-3w zRuDo`1j7;^O?x>9V6XuI02Ba#P-Qh{uOP$)gtWmLW`HDQ5SSB_0n$084YLxZ^X~yd zlKe}@GzXFf83Z6XX`__?g0qxGQZUoNUqM_`Y7F;l40}A+7;V;w#Q85nn~R(tZ+qp( zK5{~eUup2yy3}g-D9_)^%{)wZH*eR&J`TT?kJD>G+O}z&kw%+4Y2Tm6ac9g5b zfpTjZAoX+^jb5*ZiTZH6^-w8wN{oY%N3MWP?Gw5bT?aGuJOCC})+8|#hMZ+yAbI~E zHJmdrB@a9S#(vV`FwDvMm?zlMNQbNNgFKlW^#aLlG-w+2he+A>QzPzx9wF;$CBZKu zQszpoJM<1&_A&*25((ek;0k?0R#nJ=&qV%Q&vb(`HkHqX|3rSBj=I1VdrIws6(S9@ zTtuteQhY3Ik>8^@tbJw2pAoP}5=LkirQg&QjRs9EboI^43ja7PX_IiC3`hDhl0ks01e^ z)-#x4;*oXnO@#qF>u~Zi!bIyBcwpTZkSX^#Ily4BVhMMh$dR0i}u(Bj0>zLtsNzD`vL#W5h_TqZUt9%ZPm~ak( zYKlp@>D*{?lDJ| zlY>mQL%d<;YWI3Oflq{!PE6EJrZaa4DIn6+d%*Ny1`#N!-3Mkgvyb_to#YY|m@Z5` z^`;Ox%q^y}dUJ>*rWx}@y`jYRfLcJTVs0^&)mup{V!AN(wEF;ZG_#NSq+JW9J2Q>> zrQIN=B{P;t6XnWmXWkM{Ix$hZy&zXR#7B^GmHU{)91^l6b5gf>0oZ!X8K#DA{lHEX z(pkm4*33Btx(V|G(N^9bA^2ba(ijD>TLs` zTauc>%o4Rmf{%O=wldfzYLy2cYbW$nW=&kJ1}-iOT?6bvQR^YsRp|IKCi+RQFU-~^ z!XE}ZUA%u}U3}BOcGVTWaxxRGYv7f2a<#CeedUBNu?*}qG3e~F&e|FuGjkG1FheDK@qh^3`=+0tkY_-Hk;w)_e!N}h|sWoNOtLWci}+}Z@U zeZ}gRDL(ttd?Q@<3fU!%ll=7i%1XHJ6%yN$&qd3`OjDs1oOp4fkSP0l zy^&|GQcwI6eV?y3QG6S>>HIHI`PF6va+HWIXjWXb{+sC(gsc zlLy`|E84e=c^8;ns4amcdztycP?G4B3SdjOgamidD%GnLmH$Bl E01+DUL;wH) literal 0 HcmV?d00001 From d40efbb5c82e6915b27fcb8c9c7c6957e50179e8 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:04:29 -0400 Subject: [PATCH 21/82] register single-method routes with router.get/post shorthand Reserve router.route for paths that serve multiple methods. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/index.ts | 56 +++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/src/routes/index.ts b/src/routes/index.ts index 19f6b92..3ec2787 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -32,63 +32,59 @@ import { rolesByRoleGet } from "./signups/roles/by-role/get"; // handlers live in files mirroring their route: the folder is the path (with // [param] segments) and the file is the method export const registerRoutes = (router: RingRouter) => { - router.route("/", { get: homeGet }); - router.route("/commands", { get: commandsGet }); + router.get("/", homeGet); + router.get("/commands", commandsGet); // scoped panels answer their bare path as the global scope, so their // scope-switch channel select can target "{/:channelId}" and fall back to // global when the selection is cleared - router.route(["/filter", "/filter/:scope"], { get: filterGet }); - router.route("/filter/:scope/members", { post: filterMembersPost }); - router.route("/filter/:scope/type", { post: filterTypePost }); + router.get(["/filter", "/filter/:scope"], filterGet); + router.post("/filter/:scope/members", filterMembersPost); + router.post("/filter/:scope/type", filterTypePost); router.route("/filter/:scope/reset", { modal: filterResetModal, post: filterResetPost, }); - router.route(["/recipients", "/recipients/:scope"], { get: recipientsGet }); - router.route("/recipients/:scope/members", { post: recipientsMembersPost }); + router.get(["/recipients", "/recipients/:scope"], recipientsGet); + router.post("/recipients/:scope/members", recipientsMembersPost); router.route("/recipients/:scope/reset", { modal: recipientsResetModal, post: recipientsResetPost, }); - router.route("/recipients/:scope/auto-ring", { - post: recipientsAutoRingPost, - }); - router.route("/recipients/:scope/auto-ring/unset", { - post: recipientsAutoRingUnsetPost, - }); + router.post("/recipients/:scope/auto-ring", recipientsAutoRingPost); + router.post( + "/recipients/:scope/auto-ring/unset", + recipientsAutoRingUnsetPost, + ); router.route("/mode", { get: modeGet, post: modePost }); - router.route("/signups", { get: signupsGet }); - router.route("/signups/members", { post: signupsMembersPost }); + router.get("/signups", signupsGet); + router.post("/signups/members", signupsMembersPost); // role signups are a scoped panel like filter: an orientation segment // (by-channel / by-role) then the scope id. The bare path lands on the // by-channel view with nothing picked yet - router.route( + router.get( [ "/signups/roles", "/signups/roles/by-channel", "/signups/roles/by-channel/:scope", ], - { get: rolesByChannelGet }, + rolesByChannelGet, ); - router.route("/signups/roles/by-channel/:scope/roles", { - post: rolesByChannelEditPost, - }); - router.route(["/signups/roles/by-role", "/signups/roles/by-role/:scope"], { - get: rolesByRoleGet, - }); - router.route("/signups/roles/by-role/:scope/channels", { - post: rolesByRoleEditPost, - }); + router.post("/signups/roles/by-channel/:scope/roles", rolesByChannelEditPost); + router.get( + ["/signups/roles/by-role", "/signups/roles/by-role/:scope"], + rolesByRoleGet, + ); + router.post("/signups/roles/by-role/:scope/channels", rolesByRoleEditPost); - router.route("/ring", { get: ringGet }); - router.route("/ring/users", { post: ringUsersPost }); - router.route("/ring/user", { post: ringUserPost }); - router.route("/ring/default", { post: ringDefaultPost }); + router.get("/ring", ringGet); + router.post("/ring/users", ringUsersPost); + router.post("/ring/user", ringUserPost); + router.post("/ring/default", ringDefaultPost); router.route("/delete-data", { get: deleteDataGet, From 958922db67048a7e651e943bfeda5fa985641ebe Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:04:35 -0400 Subject: [PATCH 22/82] add persistent section bar to every panel Replace the per-panel back/home nav row with a shared section bar (Home, Signups, Filters, Ringees, Mode), the current section rendered as an inert Primary tab. The Signups tab carries the branded voice-chat app emoji, fetched at startup, with a unicode-bell fallback. Fold Home's old section-button row into the bar, rename Filter to Filters and ring recipients to Ringees, and restyle the catalog page onto grouped description lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/index.ts | 4 ++ src/routes/commands/get.ts | 21 +++++----- src/routes/delete-data/get.ts | 3 +- src/routes/filter/[scope]/get.ts | 4 +- src/routes/get.ts | 45 +++++----------------- src/routes/lib/components.ts | 45 +++++++++++++++++----- src/routes/lib/emoji.ts | 29 +++++++------- src/routes/mode/get.ts | 4 +- src/routes/recipients/[scope]/get.ts | 4 +- src/routes/ring/get.ts | 4 +- src/routes/signups/get.ts | 4 +- src/routes/signups/roles/_shared.ts | 9 +++-- src/routes/signups/roles/by-channel/get.ts | 1 + src/routes/signups/roles/by-role/get.ts | 1 + 14 files changed, 95 insertions(+), 83 deletions(-) diff --git a/src/index.ts b/src/index.ts index 983c8bb..848d47f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,10 @@ client.once("clientReady", async () => { console.error("Unknown command was registered: ", command.name); } }); + + // populate client.application.emojis.cache so branded emoji on buttons + // resolve; the cache is empty until fetched + await client.application?.emojis.fetch(); }); client.on("shardError", async () => { // console.log('disconnected'); diff --git a/src/routes/commands/get.ts b/src/routes/commands/get.ts index 57ce3eb..68c7808 100644 --- a/src/routes/commands/get.ts +++ b/src/routes/commands/get.ts @@ -1,4 +1,4 @@ -import { navRow } from "@routes/lib/components"; +import { navBar } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; import { Handler } from "@routes/types"; @@ -33,21 +33,22 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { ["delete_data", "data deletion panel"], ]; const list = (entries: [CommandName, string][]) => - entries.map(([name, blurb]) => `${mention(name)}: ${blurb}`).join("\n"); + entries.map(([name, blurb]) => `${mention(name)} · ${blurb}`).join("\n"); + + const description = withFlash( + state.queryParams, + "Every command is clickable.\n\n" + + `**Quick actions**\n${list(quickActions)}\n\n` + + `**Panel openers**\n${list(panels)}`, + ); return { embeds: [ new EmbedBuilder() .setColor(COLOR) .setTitle("📖 Commands") - .setDescription( - withFlash(state.queryParams, "Every command is clickable."), - ) - .addFields( - { name: "Quick actions", value: list(quickActions) }, - { name: "Panel openers", value: list(panels) }, - ), + .setDescription(description), ], - components: [navRow(router)], + components: [navBar(router, interaction)], }; }; diff --git a/src/routes/delete-data/get.ts b/src/routes/delete-data/get.ts index 2a19739..806ebc0 100644 --- a/src/routes/delete-data/get.ts +++ b/src/routes/delete-data/get.ts @@ -1,4 +1,4 @@ -import { row } from "@routes/lib/components"; +import { navBar, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { Handler } from "@routes/types"; import { RouteButtonBuilder } from "discord-embed-router"; @@ -38,6 +38,7 @@ export const deleteDataGet: Handler<"GET"> = (router, interaction, state) => { .setDisabled(done) .setTo("/"), ), + navBar(router, interaction), ], }; }; diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index 0568573..650c9af 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -1,4 +1,4 @@ -import { navRow, paginationRows, row } from "@routes/lib/components"; +import { navBar, paginationRows, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { PAGE_SIZE, paginate } from "@routes/lib/paging"; import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; @@ -101,7 +101,7 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { .setStyle(ButtonStyle.Danger) .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), ), - navRow(router), + navBar(router, interaction, "filters"), ...paginationRows(router, panelPath(scope), { page, pageCount }), ], }; diff --git a/src/routes/get.ts b/src/routes/get.ts index 234365b..8e7aeef 100644 --- a/src/routes/get.ts +++ b/src/routes/get.ts @@ -1,10 +1,5 @@ -import { row } from "@routes/lib/components"; -import { - buttonEmoji, - emojiIconURL, - RINGVC_EMOJI_ID, - VC_EMOJI_ID, -} from "@routes/lib/emoji"; +import { navBar, row } from "@routes/lib/components"; +import { emojiIconURL, RINGVC_EMOJI_ID } from "@routes/lib/emoji"; import { withFlash } from "@routes/lib/flash"; import { Handler } from "@routes/types"; import { RouteButtonBuilder } from "discord-embed-router"; @@ -16,12 +11,11 @@ const GITHUB_URL = "https://github.com/altrup/RingVC"; const SUPPORT_URL = "https://discord.gg/bxBePEnndq"; export const homeGet: Handler<"GET"> = (router, interaction, state) => { - const vc = buttonEmoji(interaction, VC_EMOJI_ID); - const brandIcon = emojiIconURL(interaction, RINGVC_EMOJI_ID); - + // the branded author line (icon + "RingVC") is the panel's header, so + // there is no separate title to duplicate it const embed = new EmbedBuilder() .setColor(COLOR) - .setTitle("🔔 RingVC") + .setAuthor({ name: "RingVC", iconURL: emojiIconURL(RINGVC_EMOJI_ID) }) .setDescription( withFlash( state.queryParams, @@ -29,34 +23,10 @@ export const homeGet: Handler<"GET"> = (router, interaction, state) => { "-# Everything the commands do lives in the panels below.", ), ); - if (brandIcon) embed.setAuthor({ name: "RingVC", iconURL: brandIcon }); - - // the Signups button carries the branded voice-channel emoji when it - // resolves, falling back to a unicode bell otherwise - const signups = new RouteButtonBuilder(router) - .setStyle(ButtonStyle.Secondary) - .setTo("/signups"); - if (vc) signups.setEmoji(vc).setLabel("Signups"); - else signups.setLabel("🔔 Signups"); return { embeds: [embed], components: [ - row( - signups, - new RouteButtonBuilder(router) - .setLabel("🛡️ Filter") - .setStyle(ButtonStyle.Secondary) - .setTo("/filter/global"), - new RouteButtonBuilder(router) - .setLabel("📣 Ring recipients") - .setStyle(ButtonStyle.Secondary) - .setTo("/recipients/global"), - new RouteButtonBuilder(router) - .setLabel("💤 Mode") - .setStyle(ButtonStyle.Secondary) - .setTo("/mode"), - ), row( new RouteButtonBuilder(router) .setLabel("📖 Commands") @@ -66,6 +36,8 @@ export const homeGet: Handler<"GET"> = (router, interaction, state) => { .setLabel("🗑️ Delete data") .setStyle(ButtonStyle.Danger) .setTo("/delete-data"), + ), + row( new ButtonBuilder() .setLabel("Github") .setStyle(ButtonStyle.Link) @@ -75,6 +47,9 @@ export const homeGet: Handler<"GET"> = (router, interaction, state) => { .setStyle(ButtonStyle.Link) .setURL(SUPPORT_URL), ), + // the section bar sits last, same as every other panel; on Home the + // active tab is Home itself, rendered inert + navBar(router, interaction, "home"), ], }; }; diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index d234600..778b74b 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -1,3 +1,4 @@ +import { buttonEmoji, VC_EMOJI_ID } from "@routes/lib/emoji"; import { Page } from "@routes/lib/paging"; import { RingButton, RingRouter } from "@routes/types"; import { RouteButtonBuilder } from "discord-embed-router"; @@ -7,6 +8,7 @@ import { APIComponentInMessageActionRow, ButtonBuilder, ButtonStyle, + Interaction, } from "discord.js"; export const row = ( @@ -30,16 +32,41 @@ export const backButton = ( .setStyle(ButtonStyle.Secondary) .setTo(path); -// the navigation row every panel ends on: Back (when the panel has a parent) -// then Home, always last and on their own row, so navigation sits in the same -// place regardless of a panel's action buttons -export const navRow = ( +// the top-level section a panel belongs to, so the section bar can render it +// as the active tab. Utility panels (Commands, Ring, Delete data) belong to no +// section and pass nothing +export type Section = "home" | "signups" | "filters" | "ringees" | "mode"; + +// the persistent section bar every panel ends on: one button per top-level +// section, always in the same place. The active section is an inert Primary so +// it reads as "you are here"; the Signups entry carries the branded voice-chat +// emoji when the bot can use it, falling back to a unicode bell +export const navBar = ( router: RingRouter, - parentPath?: string, -): APIActionRowComponent => - parentPath - ? row(backButton(router, parentPath), homeButton(router)) - : row(homeButton(router)); + interaction: Interaction, + active?: Section, +): APIActionRowComponent => { + const tab = (section: Section, label: string, path: string): RingButton => + new RouteButtonBuilder(router) + .setLabel(label) + .setStyle( + section === active ? ButtonStyle.Primary : ButtonStyle.Secondary, + ) + .setDisabled(section === active) + .setTo(path); + + const vc = buttonEmoji(interaction, VC_EMOJI_ID); + const signups = tab("signups", vc ? "Signups" : "🔔 Signups", "/signups"); + if (vc) signups.setEmoji(vc); + + return row( + tab("home", "🏠 Home", "/"), + signups, + tab("filters", "🛡️ Filters", "/filter/global"), + tab("ringees", "📣 Ringees", "/recipients/global"), + tab("mode", "💤 Mode", "/mode"), + ); +}; // the conditional pagination row: absent for single-page lists, so the // result is spread into a components array diff --git a/src/routes/lib/emoji.ts b/src/routes/lib/emoji.ts index 5828c55..a8a6d70 100644 --- a/src/routes/lib/emoji.ts +++ b/src/routes/lib/emoji.ts @@ -4,23 +4,22 @@ import { ComponentEmojiResolvable, Interaction } from "discord.js"; export const RINGVC_EMOJI_ID = "1324809350899961877"; export const VC_EMOJI_ID = "1326000686575521883"; -// custom emoji only render where the bot can resolve them; a client that -// doesn't share a guild carrying the emoji would show a broken glyph, so -// callers gate on availability and fall back to no emoji -const hasEmoji = (interaction: Interaction, id: string): boolean => - interaction.client.emojis.cache.has(id); +// the emoji image on the CDN, usable as an embed author or thumbnail icon. +// A plain image URL renders for anyone, so it needs no access check; an +// invalid id simply 404s and the icon is dropped +export const emojiIconURL = (id: string): string => + `https://cdn.discordapp.com/emojis/${id}.webp`; +// a custom emoji on a button only renders when the bot can use it: an +// application emoji it owns (fetched into client.application.emojis at +// startup) or an emoji from a guild it shares. Otherwise Discord shows a +// broken glyph, so callers fall back to no emoji export const buttonEmoji = ( interaction: Interaction, id: string, -): ComponentEmojiResolvable | undefined => - hasEmoji(interaction, id) ? { id } : undefined; - -// the animated/static emoji image, usable as an embed author or thumbnail icon -export const emojiIconURL = ( - interaction: Interaction, - id: string, -): string | undefined => - hasEmoji(interaction, id) - ? `https://cdn.discordapp.com/emojis/${id}.webp` +): ComponentEmojiResolvable | undefined => { + const client = interaction.client; + return client.application?.emojis.cache.has(id) || client.emojis.cache.has(id) + ? { id } : undefined; +}; diff --git a/src/routes/mode/get.ts b/src/routes/mode/get.ts index cffcdc2..9cb3c08 100644 --- a/src/routes/mode/get.ts +++ b/src/routes/mode/get.ts @@ -1,4 +1,4 @@ -import { navRow, row } from "@routes/lib/components"; +import { navBar, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { Handler } from "@routes/types"; import { RouteButtonBuilder } from "discord-embed-router"; @@ -44,7 +44,7 @@ export const modeGet: Handler<"GET"> = async (router, interaction, state) => { .setTo(PATH, { method: "POST", queryParams: { set: mode } }), ), ), - navRow(router), + navBar(router, interaction, "mode"), ], }; }; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 5929ff9..be2b513 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -1,4 +1,4 @@ -import { navRow, paginationRows, row } from "@routes/lib/components"; +import { navBar, paginationRows, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { PAGE_SIZE, paginate } from "@routes/lib/paging"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; @@ -144,7 +144,7 @@ export const recipientsGet: Handler<"GET"> = async ( ] : []), ), - navRow(router), + navBar(router, interaction, "ringees"), ...paginationRows(router, panelPath(scope), { page, pageCount }), ], }; diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index c608f0e..d50fedc 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -1,4 +1,4 @@ -import { homeButton, navRow, row } from "@routes/lib/components"; +import { homeButton, navBar, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { PAGE_SIZE } from "@routes/lib/paging"; import { Handler } from "@routes/types"; @@ -58,7 +58,7 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { .setStyle(ButtonStyle.Success) .setTo(`${PANEL}/default`, { method: "POST" }), ), - navRow(router), + navBar(router, interaction), ], }; }; diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index de75ea8..862f219 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -1,6 +1,6 @@ import { homeButton, - navRow, + navBar, paginationRows, row, } from "@routes/lib/components"; @@ -78,7 +78,7 @@ export const signupsGet: Handler<"GET"> = async ( .setStyle(ButtonStyle.Secondary) .setTo(ROLES), ), - navRow(router), + navBar(router, interaction, "signups"), ...paginationRows(router, PANEL, { page, pageCount }), ], }; diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index d7b1c55..11a64ee 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -1,4 +1,4 @@ -import { navRow, paginationRows, row } from "@routes/lib/components"; +import { navBar, paginationRows, row } from "@routes/lib/components"; import { flashRedirect, withFlash } from "@routes/lib/flash"; import { Page } from "@routes/lib/paging"; import { RingRouter } from "@routes/types"; @@ -9,11 +9,12 @@ import { ButtonStyle, EmbedBuilder, Guild, + Interaction, } from "discord.js"; import { joinWithAnd } from "@main/ring"; -import { COLOR, PANEL } from "../_shared"; +import { COLOR } from "../_shared"; export const BY_CHANNEL = "/signups/roles/by-channel"; export const BY_ROLE = "/signups/roles/by-role"; @@ -124,6 +125,7 @@ export const commitRoleEdit = async ({ // supplies the scope's current links; the frame, copy, and rows are shared export const renderRoleScope = ({ router, + interaction, queryParams, active, scope, @@ -139,6 +141,7 @@ export const renderRoleScope = ({ pageCount, }: { router: RingRouter; + interaction: Interaction; queryParams: URLSearchParams; active: Orientation; scope: string | null; @@ -171,7 +174,7 @@ export const renderRoleScope = ({ switchRow(router, active), scopeSelectRow, ...(scope && editSelectRow ? [editSelectRow] : []), - navRow(router, PANEL), + navBar(router, interaction, "signups"), ...paginationRows(router, basePath, { page, pageCount }), ], }; diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index 56fe1da..3f64bdf 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -76,6 +76,7 @@ export const rolesByChannelGet: Handler<"GET"> = async ( return renderRoleScope({ router, + interaction, queryParams: state.queryParams, active: "channel", scope, diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index 039ec75..12447b6 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -82,6 +82,7 @@ export const rolesByRoleGet: Handler<"GET"> = async ( return renderRoleScope({ router, + interaction, queryParams: state.queryParams, active: "role", scope, From 1b61ee767ed162fd0931505b9af7a64b9bc24c9b Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:24:49 -0400 Subject: [PATCH 23/82] paginate the section bar and add an About page Split the eight sections across two pages of four tabs, with a pager (back on the left, forward on the right) in each row's fifth slot. The bar opens on the page holding the active section, and its nav param lets the pager peek at the other page without leaving the panel. The active tab is inert only on its own root, staying a live link back on sub-pages and other scopes. Move the Github and Support links onto a dedicated About page, leaving Home as the branded welcome plus the bar. Colour the Delete data tab its destructive red when selected, and drop its now-redundant Cancel button. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/about/get.ts | 42 +++++++++++++ src/routes/commands/get.ts | 11 +++- src/routes/delete-data/get.ts | 11 ++-- src/routes/filter/[scope]/get.ts | 6 +- src/routes/get.ts | 39 +++--------- src/routes/index.ts | 2 + src/routes/lib/components.ts | 93 +++++++++++++++++++++------- src/routes/mode/get.ts | 6 +- src/routes/recipients/[scope]/get.ts | 6 +- src/routes/ring/get.ts | 5 +- src/routes/signups/get.ts | 6 +- src/routes/signups/roles/_shared.ts | 8 ++- 12 files changed, 167 insertions(+), 68 deletions(-) create mode 100644 src/routes/about/get.ts diff --git a/src/routes/about/get.ts b/src/routes/about/get.ts new file mode 100644 index 0000000..1b696f6 --- /dev/null +++ b/src/routes/about/get.ts @@ -0,0 +1,42 @@ +import { navBar, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; +import { ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; + +const COLOR = "#5865f2"; + +const GITHUB_URL = "https://github.com/altrup/RingVC"; +const SUPPORT_URL = "https://discord.gg/bxBePEnndq"; + +export const aboutGet: Handler<"GET"> = (router, interaction, state) => { + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("ℹ️ About") + .setDescription( + withFlash( + state.queryParams, + "RingVC is free and open source. Star it on GitHub, or join the support server for help and updates.", + ), + ), + ], + components: [ + row( + new ButtonBuilder() + .setLabel("Github") + .setStyle(ButtonStyle.Link) + .setURL(GITHUB_URL), + new ButtonBuilder() + .setLabel("Support Server") + .setStyle(ButtonStyle.Link) + .setURL(SUPPORT_URL), + ), + navBar(router, interaction, { + active: "about", + path: "/about", + queryParams: state.queryParams, + }), + ], + }; +}; diff --git a/src/routes/commands/get.ts b/src/routes/commands/get.ts index 68c7808..672a1d2 100644 --- a/src/routes/commands/get.ts +++ b/src/routes/commands/get.ts @@ -37,8 +37,7 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { const description = withFlash( state.queryParams, - "Every command is clickable.\n\n" + - `**Quick actions**\n${list(quickActions)}\n\n` + + `**Quick actions**\n${list(quickActions)}\n\n` + `**Panel openers**\n${list(panels)}`, ); @@ -49,6 +48,12 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { .setTitle("📖 Commands") .setDescription(description), ], - components: [navBar(router, interaction)], + components: [ + navBar(router, interaction, { + active: "commands", + path: "/commands", + queryParams: state.queryParams, + }), + ], }; }; diff --git a/src/routes/delete-data/get.ts b/src/routes/delete-data/get.ts index 806ebc0..26b44e6 100644 --- a/src/routes/delete-data/get.ts +++ b/src/routes/delete-data/get.ts @@ -32,13 +32,12 @@ export const deleteDataGet: Handler<"GET"> = (router, interaction, state) => { .setStyle(ButtonStyle.Danger) .setDisabled(done) .setTo(PANEL, { method: "MODAL" }), - new RouteButtonBuilder(router) - .setLabel("Cancel") - .setStyle(ButtonStyle.Secondary) - .setDisabled(done) - .setTo("/"), ), - navBar(router, interaction), + navBar(router, interaction, { + active: "delete", + path: PANEL, + queryParams: state.queryParams, + }), ], }; }; diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index 650c9af..e480458 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -101,7 +101,11 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { .setStyle(ButtonStyle.Danger) .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), ), - navBar(router, interaction, "filters"), + navBar(router, interaction, { + active: "filters", + path: panelPath(scope), + queryParams: state.queryParams, + }), ...paginationRows(router, panelPath(scope), { page, pageCount }), ], }; diff --git a/src/routes/get.ts b/src/routes/get.ts index 8e7aeef..c74bd28 100644 --- a/src/routes/get.ts +++ b/src/routes/get.ts @@ -1,15 +1,11 @@ -import { navBar, row } from "@routes/lib/components"; +import { navBar } from "@routes/lib/components"; import { emojiIconURL, RINGVC_EMOJI_ID } from "@routes/lib/emoji"; import { withFlash } from "@routes/lib/flash"; import { Handler } from "@routes/types"; -import { RouteButtonBuilder } from "discord-embed-router"; -import { ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; +import { EmbedBuilder } from "discord.js"; import { COLOR } from "./_shared"; -const GITHUB_URL = "https://github.com/altrup/RingVC"; -const SUPPORT_URL = "https://discord.gg/bxBePEnndq"; - export const homeGet: Handler<"GET"> = (router, interaction, state) => { // the branded author line (icon + "RingVC") is the panel's header, so // there is no separate title to duplicate it @@ -19,37 +15,18 @@ export const homeGet: Handler<"GET"> = (router, interaction, state) => { .setDescription( withFlash( state.queryParams, - "RingVC replicates group-chat voice calls in Discord servers: sign up for a voice channel and get pinged when someone starts a call there.\n\n" + - "-# Everything the commands do lives in the panels below.", + "RingVC replicates group-chat voice calls in Discord servers: sign up for a voice channel and get pinged when someone starts a call there.", ), ); return { embeds: [embed], components: [ - row( - new RouteButtonBuilder(router) - .setLabel("📖 Commands") - .setStyle(ButtonStyle.Secondary) - .setTo("/commands"), - new RouteButtonBuilder(router) - .setLabel("🗑️ Delete data") - .setStyle(ButtonStyle.Danger) - .setTo("/delete-data"), - ), - row( - new ButtonBuilder() - .setLabel("Github") - .setStyle(ButtonStyle.Link) - .setURL(GITHUB_URL), - new ButtonBuilder() - .setLabel("Support Server") - .setStyle(ButtonStyle.Link) - .setURL(SUPPORT_URL), - ), - // the section bar sits last, same as every other panel; on Home the - // active tab is Home itself, rendered inert - navBar(router, interaction, "home"), + navBar(router, interaction, { + active: "home", + path: "/", + queryParams: state.queryParams, + }), ], }; }; diff --git a/src/routes/index.ts b/src/routes/index.ts index 3ec2787..24b6d3f 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,5 +1,6 @@ import { RingRouter } from "@routes/types"; +import { aboutGet } from "./about/get"; import { commandsGet } from "./commands/get"; import { deleteDataGet } from "./delete-data/get"; import { deleteDataModal } from "./delete-data/modal"; @@ -34,6 +35,7 @@ import { rolesByRoleGet } from "./signups/roles/by-role/get"; export const registerRoutes = (router: RingRouter) => { router.get("/", homeGet); router.get("/commands", commandsGet); + router.get("/about", aboutGet); // scoped panels answer their bare path as the global scope, so their // scope-switch channel select can target "{/:channelId}" and fall back to diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 778b74b..24c5d5e 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -33,38 +33,87 @@ export const backButton = ( .setTo(path); // the top-level section a panel belongs to, so the section bar can render it -// as the active tab. Utility panels (Commands, Ring, Delete data) belong to no -// section and pass nothing -export type Section = "home" | "signups" | "filters" | "ringees" | "mode"; +// as the active tab. Panels with no section (Ring, error states) pass nothing +export type Section = + | "home" + | "signups" + | "filters" + | "ringees" + | "mode" + | "commands" + | "about" + | "delete"; -// the persistent section bar every panel ends on: one button per top-level -// section, always in the same place. The active section is an inert Primary so -// it reads as "you are here"; the Signups entry carries the branded voice-chat -// emoji when the bot can use it, falling back to a unicode bell +type Tab = { section: Section; label: string; path: string }; + +// the eight section tabs split across two pages of four; a pager fills each +// row's fifth slot, so the two pages together reach every section +const PAGE_ONE: readonly Tab[] = [ + { section: "home", label: "🏠 Home", path: "/" }, + { section: "signups", label: "🔔 Signups", path: "/signups" }, + { section: "filters", label: "🛡️ Filters", path: "/filter/global" }, + { section: "ringees", label: "📣 Ringees", path: "/recipients/global" }, +]; + +const PAGE_TWO: readonly Tab[] = [ + { section: "mode", label: "💤 Mode", path: "/mode" }, + { section: "commands", label: "📖 Commands", path: "/commands" }, + { section: "about", label: "ℹ️ About", path: "/about" }, + { section: "delete", label: "🗑️ Delete data", path: "/delete-data" }, +]; + +// the persistent section bar every panel ends on. The active section is a +// Primary tab, inert only on its own root so that on a sub-page or another +// scope it stays a live link back to that root. The Signups tab carries the +// branded voice-chat emoji when the bot can use it, else a unicode bell export const navBar = ( router: RingRouter, interaction: Interaction, - active?: Section, + { + active, + path, + queryParams, + }: { active?: Section; path: string; queryParams: URLSearchParams }, ): APIActionRowComponent => { - const tab = (section: Section, label: string, path: string): RingButton => - new RouteButtonBuilder(router) - .setLabel(label) + // open on the page holding the active section so the current tab is always + // visible; the pager's nav param overrides that to peek at the other page + // without leaving the panel + const override = queryParams.get("nav"); + const onPageTwo = + override === "2" || + (override !== "1" && PAGE_TWO.some((t) => t.section === active)); + const vc = buttonEmoji(interaction, VC_EMOJI_ID); + + const tab = ({ section, label, path: to }: Tab): RingButton => { + const button = new RouteButtonBuilder(router) + // the active tab is Primary, except Delete data reads as its + // destructive Danger red when it is the one selected .setStyle( - section === active ? ButtonStyle.Primary : ButtonStyle.Secondary, + section !== active + ? ButtonStyle.Secondary + : section === "delete" + ? ButtonStyle.Danger + : ButtonStyle.Primary, ) - .setDisabled(section === active) - .setTo(path); + .setDisabled(section === active && to === path) + .setTo(to); + if (section === "signups" && vc) button.setLabel("Signups").setEmoji(vc); + else button.setLabel(label); + return button; + }; - const vc = buttonEmoji(interaction, VC_EMOJI_ID); - const signups = tab("signups", vc ? "Signups" : "🔔 Signups", "/signups"); - if (vc) signups.setEmoji(vc); + // the pager reloads the current panel showing the other page of tabs + const pager = (label: string, page: "1" | "2"): RingButton => + new RouteButtonBuilder(router) + .setLabel(label) + .setStyle(ButtonStyle.Secondary) + .setTo(path, { queryParams: { nav: page } }); + const tabs = (onPageTwo ? PAGE_TWO : PAGE_ONE).map(tab); return row( - tab("home", "🏠 Home", "/"), - signups, - tab("filters", "🛡️ Filters", "/filter/global"), - tab("ringees", "📣 Ringees", "/recipients/global"), - tab("mode", "💤 Mode", "/mode"), + ...(onPageTwo + ? [pager("◀ Back", "1"), ...tabs] + : [...tabs, pager("More ▶", "2")]), ); }; diff --git a/src/routes/mode/get.ts b/src/routes/mode/get.ts index 9cb3c08..7c4ee71 100644 --- a/src/routes/mode/get.ts +++ b/src/routes/mode/get.ts @@ -44,7 +44,11 @@ export const modeGet: Handler<"GET"> = async (router, interaction, state) => { .setTo(PATH, { method: "POST", queryParams: { set: mode } }), ), ), - navBar(router, interaction, "mode"), + navBar(router, interaction, { + active: "mode", + path: PATH, + queryParams: state.queryParams, + }), ], }; }; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index be2b513..64e5536 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -144,7 +144,11 @@ export const recipientsGet: Handler<"GET"> = async ( ] : []), ), - navBar(router, interaction, "ringees"), + navBar(router, interaction, { + active: "ringees", + path: panelPath(scope), + queryParams: state.queryParams, + }), ...paginationRows(router, panelPath(scope), { page, pageCount }), ], }; diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index d50fedc..97aca24 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -58,7 +58,10 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { .setStyle(ButtonStyle.Success) .setTo(`${PANEL}/default`, { method: "POST" }), ), - navBar(router, interaction), + navBar(router, interaction, { + path: PANEL, + queryParams: state.queryParams, + }), ], }; }; diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index 862f219..9096b11 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -78,7 +78,11 @@ export const signupsGet: Handler<"GET"> = async ( .setStyle(ButtonStyle.Secondary) .setTo(ROLES), ), - navBar(router, interaction, "signups"), + navBar(router, interaction, { + active: "signups", + path: PANEL, + queryParams: state.queryParams, + }), ...paginationRows(router, PANEL, { page, pageCount }), ], }; diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index 11a64ee..23b8b1d 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -174,7 +174,13 @@ export const renderRoleScope = ({ switchRow(router, active), scopeSelectRow, ...(scope && editSelectRow ? [editSelectRow] : []), - navBar(router, interaction, "signups"), + navBar(router, interaction, { + active: "signups", + path: + (active === "channel" ? BY_CHANNEL : BY_ROLE) + + (scope ? `/${scope}` : ""), + queryParams, + }), ...paginationRows(router, basePath, { page, pageCount }), ], }; From d87c77e033eee90555b530a1def6dd055a29d3f7 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:18:54 -0400 Subject: [PATCH 24/82] add /ring_defaults command A dedicated fast path for ringing your saved defaults into your current voice channel, dispatching to the existing /ring/default route so it inherits the same in-VC / not-in-VC handling. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/commandNames.ts | 1 + src/commands/commands.ts | 2 ++ src/commands/ringDefaults.ts | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 src/commands/ringDefaults.ts diff --git a/src/commands/commandNames.ts b/src/commands/commandNames.ts index 7409f4e..7d5bfd0 100644 --- a/src/commands/commandNames.ts +++ b/src/commands/commandNames.ts @@ -3,6 +3,7 @@ export const commandNamesList = [ "catalog", "delete_data", "ring", + "ring_defaults", "signup", "unsignup", "signuprole", diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 2f51c4d..8b8f5d7 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -13,6 +13,7 @@ import { help } from "@commands/help"; import { mode } from "@commands/mode"; import { quit } from "@commands/quit"; import { ring } from "@commands/ring"; +import { ringDefaults } from "@commands/ringDefaults"; import { signup } from "@commands/signup"; import { signuprole } from "@commands/signuprole"; import { unsignup } from "@commands/unsignup"; @@ -33,6 +34,7 @@ export const commands: CommandImplementation[] = [ catalog, deleteData, ring, + ringDefaults, signup, unsignup, signuprole, diff --git a/src/commands/ringDefaults.ts b/src/commands/ringDefaults.ts new file mode 100644 index 0000000..125d3fc --- /dev/null +++ b/src/commands/ringDefaults.ts @@ -0,0 +1,18 @@ +import { RingRouter } from "@routes/types"; +import { + ChatInputCommandInteraction, + MessageFlags, + SlashCommandBuilder, +} from "discord.js"; + +export const ringDefaults = { + data: new SlashCommandBuilder() + .setName("ring_defaults") + .setDescription("Ring your default recipients into your voice channel"), + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await router.dispatch(interaction, "/ring/default", { + method: "POST", + flags: [MessageFlags.Ephemeral], + }); + }, +}; From b78f86f30ef8195bbd437ec5369d9d1ecca7d297 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:19:04 -0400 Subject: [PATCH 25/82] unify sub-navigation and restructure the Ring section Add a reusable subNav row (the old role-signups switch, generalized) that sits just above the section bar wherever a section has sibling views. Signups shows My signups / Role signups (the latter only with Manage Roles), and role signups moves its By channel / By role switch below its selects. Split Ring into Quick ring (the immediate action) and Default ringees (the saved list plus auto-ring). The bar's Ring tab is voice-aware, opening Quick ring in a voice channel and Default ringees otherwise, so it never lands on the "not in a voice channel" notice by default. Default ringees inlines prev/next into its action row, keeping the dense panel within Discord's five-row limit. A channel auto-ring override now drops when set back to the global value, so the manual "Remove override" button, route, and handler are gone. Rename ring recipients to ringees throughout, link /ring_defaults from the Default ringees panel, and key the subNav buttons so they never collide with the bar's customIds. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/db/auto-ring.ts | 19 +++++--- src/routes/commands/get.ts | 1 + src/routes/index.ts | 5 --- src/routes/lib/components.ts | 35 +++++++++++++-- .../[scope]/auto-ring/unset/post.ts | 36 --------------- src/routes/recipients/[scope]/get.ts | 44 ++++++++++++------- src/routes/recipients/recipients.test.ts | 20 +-------- src/routes/ring/_shared.ts | 2 +- src/routes/ring/get.ts | 27 +++++++++--- src/routes/signups/get.ts | 32 +++++++------- src/routes/signups/roles/_shared.ts | 33 +++----------- 11 files changed, 120 insertions(+), 134 deletions(-) delete mode 100644 src/routes/recipients/[scope]/auto-ring/unset/post.ts diff --git a/src/main/db/auto-ring.ts b/src/main/db/auto-ring.ts index 65d1f2b..266250f 100644 --- a/src/main/db/auto-ring.ts +++ b/src/main/db/auto-ring.ts @@ -42,12 +42,21 @@ export const setAutoRing = async ( enabled: boolean, ): Promise => { const current = await getAutoRingSetting(userId, channelId); - // for the global scope, an unset value behaves as false - if ( - current === enabled || - (channelId === null && current === undefined && !enabled) - ) + + if (channelId !== null) { + // a channel override only exists when it differs from the global + // setting, so setting it back to the global value drops it entirely + const globalEnabled = (await getAutoRingSetting(userId, null)) ?? false; + const effective = current ?? globalEnabled; + if (enabled === effective) return false; + if (enabled === globalEnabled) { + await unsetAutoRing(userId, channelId); + return true; + } + } else if (current === enabled || (current === undefined && !enabled)) { + // global scope: an unset value behaves as false return false; + } await ensureUser(userId); throwOnError( diff --git a/src/routes/commands/get.ts b/src/routes/commands/get.ts index 672a1d2..7d94be0 100644 --- a/src/routes/commands/get.ts +++ b/src/routes/commands/get.ts @@ -21,6 +21,7 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { ["whitelist", "allow someone through your whitelist"], ["unwhitelist", "remove them from your whitelist"], ["ring", "ring someone into your voice channel (with a user)"], + ["ring_defaults", "ring your saved defaults into your voice channel"], ]; const panels: [CommandName, string][] = [ ["help", "the home panel"], diff --git a/src/routes/index.ts b/src/routes/index.ts index 24b6d3f..8f33787 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -14,7 +14,6 @@ import { homeGet } from "./get"; import { modeGet } from "./mode/get"; import { modePost } from "./mode/post"; import { recipientsAutoRingPost } from "./recipients/[scope]/auto-ring/post"; -import { recipientsAutoRingUnsetPost } from "./recipients/[scope]/auto-ring/unset/post"; import { recipientsGet } from "./recipients/[scope]/get"; import { recipientsMembersPost } from "./recipients/[scope]/members/post"; import { recipientsResetModal } from "./recipients/[scope]/reset/modal"; @@ -55,10 +54,6 @@ export const registerRoutes = (router: RingRouter) => { post: recipientsResetPost, }); router.post("/recipients/:scope/auto-ring", recipientsAutoRingPost); - router.post( - "/recipients/:scope/auto-ring/unset", - recipientsAutoRingUnsetPost, - ); router.route("/mode", { get: modeGet, post: modePost }); diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 24c5d5e..12049f2 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -52,7 +52,7 @@ const PAGE_ONE: readonly Tab[] = [ { section: "home", label: "🏠 Home", path: "/" }, { section: "signups", label: "🔔 Signups", path: "/signups" }, { section: "filters", label: "🛡️ Filters", path: "/filter/global" }, - { section: "ringees", label: "📣 Ringees", path: "/recipients/global" }, + { section: "ringees", label: "📣 Ring", path: "/recipients/global" }, ]; const PAGE_TWO: readonly Tab[] = [ @@ -83,8 +83,17 @@ export const navBar = ( override === "2" || (override !== "1" && PAGE_TWO.some((t) => t.section === active)); const vc = buttonEmoji(interaction, VC_EMOJI_ID); + // the Ring section lands on the immediate ring action when the user is in a + // voice channel, and on the default-recipients settings otherwise, so the + // tab never opens the "not in a voice channel" notice by default + const inVoice = !!( + interaction.member && + "voice" in interaction.member && + interaction.member.voice.channel + ); const tab = ({ section, label, path: to }: Tab): RingButton => { + const target = section === "ringees" && inVoice ? "/ring" : to; const button = new RouteButtonBuilder(router) // the active tab is Primary, except Delete data reads as its // destructive Danger red when it is the one selected @@ -95,8 +104,8 @@ export const navBar = ( ? ButtonStyle.Danger : ButtonStyle.Primary, ) - .setDisabled(section === active && to === path) - .setTo(to); + .setDisabled(section === active && target === path) + .setTo(target); if (section === "signups" && vc) button.setLabel("Signups").setEmoji(vc); else button.setLabel(label); return button; @@ -117,6 +126,26 @@ export const navBar = ( ); }; +// the sub-view switch a section shows just above the bar when it has sibling +// views (e.g. My signups / Role signups, Ring now / Default ringees). The +// current view is an inert Primary; the others are Secondary links +export const subNav = ( + router: RingRouter, + items: { label: string; path: string; active?: boolean }[], +): APIActionRowComponent => + row( + ...items.map((item) => + new RouteButtonBuilder(router) + .setLabel(item.label) + .setStyle(item.active ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setDisabled(item.active ?? false) + // a sub-nav item can point at the same path as the bar's active tab + // (e.g. Default ringees / the Ring tab); the key keeps their + // customIds distinct, which Discord requires within one message + .setTo(item.path, { key: "subnav" }), + ), + ); + // the conditional pagination row: absent for single-page lists, so the // result is spread into a components array export const paginationRows = ( diff --git a/src/routes/recipients/[scope]/auto-ring/unset/post.ts b/src/routes/recipients/[scope]/auto-ring/unset/post.ts deleted file mode 100644 index d2f1f78..0000000 --- a/src/routes/recipients/[scope]/auto-ring/unset/post.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { flashRedirect } from "@routes/lib/flash"; -import { channelIdOf, scopeOf } from "@routes/lib/scope"; -import { Handler } from "@routes/types"; - -import { unsetAutoRing } from "@db/auto-ring"; - -import { panelPath } from "../../../_shared"; - -export const recipientsAutoRingUnsetPost: Handler<"POST"> = async ( - router, - interaction, - state, -) => { - const scope = scopeOf(state.params); - const channelId = channelIdOf(scope); - const panel = panelPath(scope); - if (channelId === null) - return flashRedirect( - panel, - "Only channel scopes have an auto-ring override to remove", - "warn", - ); - - const existed = await unsetAutoRing(interaction.user.id, channelId); - return existed - ? flashRedirect( - panel, - `Removed the auto-ring override for <#${channelId}>; your global setting applies again`, - "success", - ) - : flashRedirect( - panel, - `You have no auto-ring override for <#${channelId}>`, - "warn", - ); -}; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 64e5536..03ded15 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -1,5 +1,6 @@ -import { navBar, paginationRows, row } from "@routes/lib/components"; +import { navBar, row, subNav } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; import { PAGE_SIZE, paginate } from "@routes/lib/paging"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; @@ -70,9 +71,9 @@ export const recipientsGet: Handler<"GET"> = async ( pageItems.length > 0 ? pageItems.map(mentionUser).join(" ") : "None"; const description = withFlash( state.queryParams, - `These people get rung when you use Ring defaults${channelId ? ` in <#${channelId}>` : ""}, or on every voice channel join if auto-ring is on.\n\n` + + `These people get rung when you use ${commandMention(state.globals, "ring_defaults")}${channelId ? ` in <#${channelId}>` : ""}, or on every voice channel join if auto-ring is on.\n\n` + `**Auto-ring** · ${autoRingValue}\n` + - `**Recipients${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${memberList}`, + `**Ringees${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${memberList}`, ); return { @@ -80,9 +81,7 @@ export const recipientsGet: Handler<"GET"> = async ( new EmbedBuilder() .setColor(COLOR) .setTitle( - scope === "global" - ? "📣 Default ring recipients" - : "📣 Channel ring recipients", + scope === "global" ? "📣 Default ringees" : "📣 Channel ringees", ) .setDescription(description), ], @@ -95,8 +94,8 @@ export const recipientsGet: Handler<"GET"> = async ( .setMaxValues(1) .setPlaceholder( channelId - ? "Viewing channel recipients (clear for global)" - : "View a voice channel's recipients", + ? "Viewing channel ringees (clear for global)" + : "View a voice channel's ringees", ) .setDefaultChannels(...(channelId ? [channelId] : [])) .setPattern("/recipients{/:channelId}"), @@ -107,9 +106,7 @@ export const recipientsGet: Handler<"GET"> = async ( new RouteUserSelectMenuBuilder(router) .setMinValues(0) .setMaxValues(PAGE_SIZE) - .setPlaceholder( - "Edit recipients: select to add, deselect to remove", - ) + .setPlaceholder("Edit ringees: select to add, deselect to remove") .setDefaultUsers(...pageItems) .setPattern(`${panelPath(scope)}/members`, { method: "POST", @@ -118,6 +115,18 @@ export const recipientsGet: Handler<"GET"> = async ( ) .toJSON(), row( + // prev/next page the recipient list inline, so this dense panel + // spends no separate row on pagination + ...(page > 0 + ? [ + new RouteButtonBuilder(router) + .setLabel("◀") + .setStyle(ButtonStyle.Secondary) + .setTo(panelPath(scope), { + queryParams: { page: String(page - 1) }, + }), + ] + : []), new RouteButtonBuilder(router) .setLabel( autoRing.effective ? "Disable auto-ring" : "Enable auto-ring", @@ -133,23 +142,26 @@ export const recipientsGet: Handler<"GET"> = async ( .setLabel("Reset") .setStyle(ButtonStyle.Danger) .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), - ...(channelId !== null && autoRing.override !== undefined + ...(page < pageCount - 1 ? [ new RouteButtonBuilder(router) - .setLabel("Remove override") + .setLabel("▶") .setStyle(ButtonStyle.Secondary) - .setTo(`${panelPath(scope)}/auto-ring/unset`, { - method: "POST", + .setTo(panelPath(scope), { + queryParams: { page: String(page + 1) }, }), ] : []), ), + subNav(router, [ + { label: "Quick ring", path: "/ring" }, + { label: "Default ringees", path: panelPath(scope), active: true }, + ]), navBar(router, interaction, { active: "ringees", path: panelPath(scope), queryParams: state.queryParams, }), - ...paginationRows(router, panelPath(scope), { page, pageCount }), ], }; }; diff --git a/src/routes/recipients/recipients.test.ts b/src/routes/recipients/recipients.test.ts index e311228..3656d7c 100644 --- a/src/routes/recipients/recipients.test.ts +++ b/src/routes/recipients/recipients.test.ts @@ -1,17 +1,15 @@ import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; -import { setAutoRing, unsetAutoRing } from "@db/auto-ring"; +import { setAutoRing } from "@db/auto-ring"; import { resetDefaultRingees } from "@db/default-ringees"; import { recipientsAutoRingPost } from "./[scope]/auto-ring/post"; -import { recipientsAutoRingUnsetPost } from "./[scope]/auto-ring/unset/post"; import { recipientsResetPost } from "./[scope]/reset/post"; vi.mock("@db/auto-ring", () => ({ getAutoRingSetting: vi.fn(), setAutoRing: vi.fn(), - unsetAutoRing: vi.fn(), })); vi.mock("@db/default-ringees", () => ({ getDefaultRingees: vi.fn(), @@ -97,19 +95,3 @@ test("toggling auto-ring to its current value reports no change", async () => { ); expect(flashParams.get("flash")).toContain("already"); }); - -test("removing a missing auto-ring override reports there was none", async () => { - vi.mocked(unsetAutoRing).mockResolvedValue(false); - - const result = await recipientsAutoRingUnsetPost( - undefined as never, - interaction, - autoRingState("123", ""), - ); - - expect(unsetAutoRing).toHaveBeenCalledExactlyOnceWith("caller", "123"); - const flashParams = new URLSearchParams( - result.queryParams as Record, - ); - expect(flashParams.get("level")).toBe("warn"); -}); diff --git a/src/routes/ring/_shared.ts b/src/routes/ring/_shared.ts index 93fefdd..e36353e 100644 --- a/src/routes/ring/_shared.ts +++ b/src/routes/ring/_shared.ts @@ -11,7 +11,7 @@ import { export const PANEL = "/ring"; export const NOT_IN_VC = - "RingVC needs to know which voice channel to ring people into. Join one, then run /ring again."; + "RingVC needs to know which voice channel to ring people in. Join one, then run /ring again."; // panels outlive the voice state that was true at render time, so every // mutation re-checks it at click time diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index 97aca24..b18b71f 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -1,4 +1,4 @@ -import { homeButton, navBar, row } from "@routes/lib/components"; +import { navBar, row, subNav } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { PAGE_SIZE } from "@routes/lib/paging"; import { Handler } from "@routes/types"; @@ -16,30 +16,43 @@ import { NOT_IN_VC, PANEL, voiceChannelOf } from "./_shared"; const COLOR = "#c87a6d"; export const ringGet: Handler<"GET"> = async (router, interaction, state) => { + // the Ring section's two sibling views; this panel is the "Ring now" one + const ringViews = subNav(router, [ + { label: "Quick ring", path: PANEL, active: true }, + { label: "Default ringees", path: "/recipients/global" }, + ]); + const channel = voiceChannelOf(interaction); if (!channel) return { embeds: [ new EmbedBuilder() .setColor(COLOR) - .setTitle("Ring") + .setTitle("📣 Quick ring") .setDescription(`⚠️ ${NOT_IN_VC}`), ], - components: [row(homeButton(router))], + components: [ + ringViews, + navBar(router, interaction, { + active: "ringees", + path: PANEL, + queryParams: state.queryParams, + }), + ], }; const defaults = await getAllDefaultRingees(interaction.user.id, channel.id); const description = withFlash( state.queryParams, - `Ringing people into <#${channel.id}>.\n\n` + - `**Your defaults here** · ${defaults.length > 0 ? joinWithAnd(defaults.map(mentionUser)) : "None"}`, + `Ringing people in <#${channel.id}>.\n\n` + + `**Your defaults** · ${defaults.length > 0 ? joinWithAnd(defaults.map(mentionUser)) : "None"}`, ); return { embeds: [ new EmbedBuilder() .setColor(COLOR) - .setTitle("📣 Ring") + .setTitle("📣 Quick ring") .setDescription(description), ], components: [ @@ -58,7 +71,9 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { .setStyle(ButtonStyle.Success) .setTo(`${PANEL}/default`, { method: "POST" }), ), + ringViews, navBar(router, interaction, { + active: "ringees", path: PANEL, queryParams: state.queryParams, }), diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index 9096b11..4ac8724 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -3,22 +3,16 @@ import { navBar, paginationRows, row, + subNav, } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { PAGE_SIZE, paginate } from "@routes/lib/paging"; import { Handler } from "@routes/types"; -import { - RouteButtonBuilder, - RouteChannelSelectMenuBuilder, -} from "discord-embed-router"; -import { - ActionRowBuilder, - ButtonStyle, - ChannelType, - EmbedBuilder, -} from "discord.js"; +import { RouteChannelSelectMenuBuilder } from "discord-embed-router"; +import { ActionRowBuilder, ChannelType, EmbedBuilder } from "discord.js"; import { + canManageRoleSignups, COLOR, guildOnlyRender, guildSignups, @@ -72,18 +66,22 @@ export const signupsGet: Handler<"GET"> = async ( }), ) .toJSON(), - row( - new RouteButtonBuilder(router) - .setLabel("Role signups") - .setStyle(ButtonStyle.Secondary) - .setTo(ROLES), - ), + ...paginationRows(router, PANEL, { page, pageCount }), + // the switch into role signups is a management action, so it only + // appears for members who can manage role signups + ...(canManageRoleSignups(interaction) + ? [ + subNav(router, [ + { label: "My signups", path: PANEL, active: true }, + { label: "Role signups", path: ROLES }, + ]), + ] + : []), navBar(router, interaction, { active: "signups", path: PANEL, queryParams: state.queryParams, }), - ...paginationRows(router, PANEL, { page, pageCount }), ], }; }; diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index 23b8b1d..4a8610f 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -1,12 +1,11 @@ -import { navBar, paginationRows, row } from "@routes/lib/components"; +import { navBar, paginationRows, subNav } from "@routes/lib/components"; import { flashRedirect, withFlash } from "@routes/lib/flash"; import { Page } from "@routes/lib/paging"; import { RingRouter } from "@routes/types"; -import { RouteButtonBuilder, RouteRedirect } from "discord-embed-router"; +import { RouteRedirect } from "discord-embed-router"; import { APIActionRowComponent, APIComponentInMessageActionRow, - ButtonStyle, EmbedBuilder, Guild, Interaction, @@ -25,27 +24,6 @@ export type Orientation = "channel" | "role"; const LEAD = "When someone joins a voice channel, its signed-up roles get pinged."; -// the orientation toggle: the active view is an inert Primary, the other a -// Secondary link that resets the scope (a channel id is not a role id) -const switchRow = ( - router: RingRouter, - active: Orientation, -): APIActionRowComponent => - row( - new RouteButtonBuilder(router) - .setLabel("By channel") - .setStyle( - active === "channel" ? ButtonStyle.Primary : ButtonStyle.Secondary, - ) - .setDisabled(active === "channel") - .setTo(BY_CHANNEL), - new RouteButtonBuilder(router) - .setLabel("By role") - .setStyle(active === "role" ? ButtonStyle.Primary : ButtonStyle.Secondary) - .setDisabled(active === "role") - .setTo(BY_ROLE), - ); - // the scope carried in the path, or null when nothing is picked yet (unlike // filter/recipients, role signups have no global scope to fall back to) export const roleScopeOf = ( @@ -171,9 +149,13 @@ export const renderRoleScope = ({ .setDescription(withFlash(queryParams, body)), ], components: [ - switchRow(router, active), scopeSelectRow, ...(scope && editSelectRow ? [editSelectRow] : []), + ...paginationRows(router, basePath, { page, pageCount }), + subNav(router, [ + { label: "By channel", path: BY_CHANNEL, active: active === "channel" }, + { label: "By role", path: BY_ROLE, active: active === "role" }, + ]), navBar(router, interaction, { active: "signups", path: @@ -181,7 +163,6 @@ export const renderRoleScope = ({ (scope ? `/${scope}` : ""), queryParams, }), - ...paginationRows(router, basePath, { page, pageCount }), ], }; }; From 2573969eca8a1089a26235548aa9f4f12367dc48 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:25:06 -0400 Subject: [PATCH 26/82] render the flash notice at the bottom of the embed Move the action acknowledgement below the panel body instead of between the title and the body. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/lib/flash.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/routes/lib/flash.ts b/src/routes/lib/flash.ts index 569c8b0..d1b57bb 100644 --- a/src/routes/lib/flash.ts +++ b/src/routes/lib/flash.ts @@ -24,7 +24,7 @@ const boldLead = (text: string): string => { return `**${text.slice(0, periodIndex + 1)}**${text.slice(periodIndex + 1)}`; }; -// the notice a panel renders at the top of its embed, as a markdown +// the notice a panel renders at the bottom of its embed, as a markdown // blockquote (Discord draws a grey left bar) so it separates from the body; // the level picks the icon and the opening clause is bolded export const flashLine = (queryParams: URLSearchParams): string | null => { @@ -37,12 +37,12 @@ export const flashLine = (queryParams: URLSearchParams): string | null => { .join("\n"); }; -// an embed description with the flash notice, when present, above the body, -// always with one blank line between the blockquote and the body +// an embed description with the flash notice, when present, below the body, +// always with one blank line between the body and the blockquote export const withFlash = ( queryParams: URLSearchParams, body: string, ): string => { const line = flashLine(queryParams); - return line ? `${line}\n\n${body}` : body; + return line ? `${body}\n\n${line}` : body; }; From 857fe0e1bb8c0d9e79ddcb8b20f992c833ec5424 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:26:19 -0400 Subject: [PATCH 27/82] keep filter entries when switching whitelist/blacklist Switching the filter type now reinterprets the existing list (blocked users become allowed users, and vice versa) instead of clearing it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/db/filters.ts | 9 ++------- src/routes/filter/[scope]/type/post.ts | 4 ++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/main/db/filters.ts b/src/main/db/filters.ts index c0eefe8..ae1a3df 100644 --- a/src/main/db/filters.ts +++ b/src/main/db/filters.ts @@ -177,7 +177,8 @@ export const removeFilterEntry = async ( return deleted.length > 0; }; -// sets the filter type and clears its entries +// sets the filter type, keeping its entries (the same list is reinterpreted: +// a blacklist's blocked users become a whitelist's allowed users) export const setFilterType = async ( userId: string, channelId: string | null, @@ -192,12 +193,6 @@ export const setFilterType = async ( { onConflict: "user_id,channel_id" }, ), ); - throwOnError( - await scopeFilter( - db.from("filter_entries").delete().eq("user_id", userId), - channelId, - ), - ); }; // deletes the filter for a scope entirely (back to the default: an empty diff --git a/src/routes/filter/[scope]/type/post.ts b/src/routes/filter/[scope]/type/post.ts index 57ebe17..f10d2b5 100644 --- a/src/routes/filter/[scope]/type/post.ts +++ b/src/routes/filter/[scope]/type/post.ts @@ -28,7 +28,7 @@ export const filterTypePost: Handler<"POST"> = async ( await setFilterType(interaction.user.id, channelId, to === "whitelist"); return flashRedirect( panel, - `${scopeName(scope, "filter", { capitalize: true })} was reset and changed to a ${to}`, - "warn", + `${scopeName(scope, "filter", { capitalize: true })} is now a ${to}`, + "success", ); }; From 5c0a67c2df3bf3d95eaeef882e3e67b0d421709c Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:20:29 -0400 Subject: [PATCH 28/82] fix paging boundary, multi-line flash bolding, and test env coupling - paginate: an exactly-full list (a multiple of PAGE_SIZE) now gets a trailing empty page, so a 26th member can still be added at 25 entries - flashLine: bold only the first line's lead so the ** markers don't span blockquote lines and render literally on multi-line flashes - extract resolveSelectionEdit so the filter/recipients/signups member editors share the select-vs-query-param diff logic; recipients now honors add/remove query params like the others - signups: run the add/remove mutations with Promise.all instead of sequentially awaiting each - vitest: inject dummy env so the suite is self-contained and no longer depends on a developer's .env Co-Authored-By: Claude Opus 4.8 --- src/routes/filter/[scope]/members/post.ts | 20 ++----- src/routes/lib/flash.test.ts | 11 ++++ src/routes/lib/flash.ts | 6 +- src/routes/lib/paging.test.ts | 14 ++++- src/routes/lib/paging.ts | 38 +++++++++++-- src/routes/recipients/[scope]/members/post.ts | 14 +++-- src/routes/signups/members/post.ts | 57 ++++++++++--------- vitest.config.mts | 10 ++++ 8 files changed, 113 insertions(+), 57 deletions(-) diff --git a/src/routes/filter/[scope]/members/post.ts b/src/routes/filter/[scope]/members/post.ts index 7cf1a17..a1cc3cb 100644 --- a/src/routes/filter/[scope]/members/post.ts +++ b/src/routes/filter/[scope]/members/post.ts @@ -1,5 +1,5 @@ import { flashRedirect } from "@routes/lib/flash"; -import { diffSelection, paginate } from "@routes/lib/paging"; +import { resolveSelectionEdit } from "@routes/lib/paging"; import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; @@ -46,19 +46,11 @@ export const filterMembersPost: Handler<"POST"> = async ( ); const entries = [...(filter?.entries ?? [])].sort(); - let addsRequested: string[]; - let removesRequested: string[]; - if (state.values) { - const { pageItems } = paginate(entries, query.get("page")); - ({ added: addsRequested, removed: removesRequested } = diffSelection({ - allItems: entries, - pageItems, - submitted: state.values, - })); - } else { - addsRequested = query.getAll("add"); - removesRequested = query.getAll("remove"); - } + const { addsRequested, removesRequested } = resolveSelectionEdit({ + current: entries, + values: state.values, + queryParams: query, + }); const entrySet = new Set(entries); const toAdd = addsRequested.filter((id) => !entrySet.has(id)); diff --git a/src/routes/lib/flash.test.ts b/src/routes/lib/flash.test.ts index c6dbc0f..ba64924 100644 --- a/src/routes/lib/flash.test.ts +++ b/src/routes/lib/flash.test.ts @@ -39,6 +39,17 @@ test("flashLine bolds only the opening clause through the first period", () => { ).toBe("> ✅ **Auto-ring enabled.** You'll ring these people."); }); +test("flashLine keeps the bold lead within the first line of a multi-line flash", () => { + expect( + flashLine( + new URLSearchParams({ + flash: "Rang @a\nCan't ring @b because they blocked you.", + level: "warn", + }), + ), + ).toBe("> ⚠️ **Rang @a**\n> Can't ring @b because they blocked you."); +}); + test("flashLine returns null when no flash is present", () => { expect(flashLine(new URLSearchParams())).toBeNull(); expect(flashLine(new URLSearchParams({ flash: "" }))).toBeNull(); diff --git a/src/routes/lib/flash.ts b/src/routes/lib/flash.ts index d1b57bb..c71d03c 100644 --- a/src/routes/lib/flash.ts +++ b/src/routes/lib/flash.ts @@ -31,8 +31,10 @@ export const flashLine = (queryParams: URLSearchParams): string | null => { const flash = queryParams.get("flash"); if (!flash) return null; const icon = queryParams.get("level") === "warn" ? "⚠️" : "✅"; - return `${icon} ${boldLead(flash)}` - .split("\n") + // bold only the first line's lead: Discord bold can't span the newline + // between blockquote lines, so the markers must open and close on one line + const [first = "", ...rest] = flash.split("\n"); + return [`${icon} ${boldLead(first)}`, ...rest] .map((line) => `> ${line}`) .join("\n"); }; diff --git a/src/routes/lib/paging.test.ts b/src/routes/lib/paging.test.ts index 5e71d67..f8dd233 100644 --- a/src/routes/lib/paging.test.ts +++ b/src/routes/lib/paging.test.ts @@ -4,14 +4,22 @@ import { expect, test } from "vitest"; const ids = (count: number, offset = 0) => Array.from({ length: count }, (_, i) => `${i + offset + 1}`); -test("lists of 25 or fewer fit one page with no pagination", () => { - expect(paginate(ids(25), null)).toStrictEqual({ - pageItems: ids(25), +test("a partial page fits one page with no pagination", () => { + expect(paginate(ids(24), null)).toStrictEqual({ + pageItems: ids(24), page: 0, pageCount: 1, }); }); +test("an exactly full page is followed by an empty page for adding", () => { + expect(paginate(ids(25), "1")).toStrictEqual({ + pageItems: [], + page: 1, + pageCount: 2, + }); +}); + test("lists over 25 split into pages of 25", () => { const items = ids(30); expect(paginate(items, "1")).toStrictEqual({ diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts index 840e69d..94f69fb 100644 --- a/src/routes/lib/paging.ts +++ b/src/routes/lib/paging.ts @@ -7,12 +7,12 @@ export type Page = { pageCount: number; }; -// slices one select-menu page out of a list. Lists over PAGE_SIZE get an -// extra empty page after the last full one, so adding stays possible even -// when every existing page is full; stale page indexes clamp to the last page +// slices one select-menu page out of a list. The last page is always +// partial (a length that is an exact multiple of PAGE_SIZE gets a trailing +// empty page), so adding stays possible even when every existing page is +// full; stale page indexes clamp to the last page export const paginate = (items: string[], rawPage: string | null): Page => { - const pageCount = - items.length <= PAGE_SIZE ? 1 : Math.floor(items.length / PAGE_SIZE) + 1; + const pageCount = Math.floor(items.length / PAGE_SIZE) + 1; const parsed = parseInt(rawPage ?? "0"); const page = Math.min(Math.max(isNaN(parsed) ? 0 : parsed, 0), pageCount - 1); return { @@ -42,3 +42,31 @@ export const diffSelection = ({ removed: pageItems.filter((value) => !submittedSet.has(value)), }; }; + +// resolves an add/remove edit for a paged member select from either input a +// handler can receive: a select submission (diffed against the visible page) +// or `add`/`remove` query params from a command adapter. Callers still filter +// the result against their current set before mutating +export const resolveSelectionEdit = ({ + current, + values, + queryParams, +}: { + current: string[]; + values: string[] | undefined; + queryParams: URLSearchParams; +}): { addsRequested: string[]; removesRequested: string[] } => { + if (values) { + const { pageItems } = paginate(current, queryParams.get("page")); + const { added, removed } = diffSelection({ + allItems: current, + pageItems, + submitted: values, + }); + return { addsRequested: added, removesRequested: removed }; + } + return { + addsRequested: queryParams.getAll("add"), + removesRequested: queryParams.getAll("remove"), + }; +}; diff --git a/src/routes/recipients/[scope]/members/post.ts b/src/routes/recipients/[scope]/members/post.ts index a879e48..3614f81 100644 --- a/src/routes/recipients/[scope]/members/post.ts +++ b/src/routes/recipients/[scope]/members/post.ts @@ -1,5 +1,5 @@ import { flashRedirect } from "@routes/lib/flash"; -import { diffSelection, paginate } from "@routes/lib/paging"; +import { resolveSelectionEdit } from "@routes/lib/paging"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; @@ -23,12 +23,14 @@ export const recipientsMembersPost: Handler<"POST"> = async ( const panel = panelPath(scope); const ringees = [...(await getDefaultRingees(userId, channelId))].sort(); - const { pageItems } = paginate(ringees, state.queryParams.get("page")); - const { added, removed } = diffSelection({ - allItems: ringees, - pageItems, - submitted: state.values ?? pageItems, + const { addsRequested, removesRequested } = resolveSelectionEdit({ + current: ringees, + values: state.values, + queryParams: state.queryParams, }); + const ringeeSet = new Set(ringees); + const added = addsRequested.filter((id) => !ringeeSet.has(id)); + const removed = removesRequested.filter((id) => ringeeSet.has(id)); await Promise.all([ ...added.map((id) => addDefaultRingee(userId, channelId, id)), diff --git a/src/routes/signups/members/post.ts b/src/routes/signups/members/post.ts index ee3dfb2..c42d8d7 100644 --- a/src/routes/signups/members/post.ts +++ b/src/routes/signups/members/post.ts @@ -1,5 +1,5 @@ import { flashRedirect } from "@routes/lib/flash"; -import { diffSelection, paginate } from "@routes/lib/paging"; +import { resolveSelectionEdit } from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { addVoiceChatUser, removeVoiceChatUser } from "@db/voice-chats"; @@ -24,33 +24,36 @@ export const signupsMembersPost: Handler<"POST"> = async ( const userId = interaction.user.id; const query = state.queryParams; - let addsRequested: string[]; - let removesRequested: string[]; - if (state.values) { - const signups = await guildSignups(userId, guild); - const { pageItems } = paginate(signups, query.get("page")); - ({ added: addsRequested, removed: removesRequested } = diffSelection({ - allItems: signups, - pageItems, - submitted: state.values, - })); - } else { - addsRequested = query.getAll("add"); - removesRequested = query.getAll("remove"); - } + // only the select path needs the current signups to diff against; command + // adapters pass the ids to add/remove directly, so skip the fetch for them + const signups = state.values ? await guildSignups(userId, guild) : []; + const { addsRequested, removesRequested } = resolveSelectionEdit({ + current: signups, + values: state.values, + queryParams: query, + }); - const added: string[] = []; - const alreadySignedUp: string[] = []; - for (const channelId of addsRequested) { - if (await addVoiceChatUser(channelId, userId)) added.push(channelId); - else alreadySignedUp.push(channelId); - } - const removed: string[] = []; - const notSignedUp: string[] = []; - for (const channelId of removesRequested) { - if (await removeVoiceChatUser(channelId, userId)) removed.push(channelId); - else notSignedUp.push(channelId); - } + const addResults = await Promise.all( + addsRequested.map(async (channelId) => ({ + channelId, + ok: await addVoiceChatUser(channelId, userId), + })), + ); + const added = addResults.filter((r) => r.ok).map((r) => r.channelId); + const alreadySignedUp = addResults + .filter((r) => !r.ok) + .map((r) => r.channelId); + + const removeResults = await Promise.all( + removesRequested.map(async (channelId) => ({ + channelId, + ok: await removeVoiceChatUser(channelId, userId), + })), + ); + const removed = removeResults.filter((r) => r.ok).map((r) => r.channelId); + const notSignedUp = removeResults + .filter((r) => !r.ok) + .map((r) => r.channelId); const parts = [ ...(added.length > 0 diff --git a/vitest.config.mts b/vitest.config.mts index 424b024..dd07028 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -17,5 +17,15 @@ export default defineConfig({ }, test: { include: ["src/**/*.test.ts"], + // config.ts and the supabase client validate these at import, and test + // modules pull them in transitively (e.g. mocking a db module with its + // real implementation). Dummy values keep the suite self-contained so it + // never depends on a developer's .env; the db layer is mocked in tests + env: { + DISCORD_TOKEN: "test-token", + DISCORD_CLIENT_ID: "test-client-id", + SUPABASE_URL: "http://localhost:54321", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + }, }, }); From 2b980ae8df60b164a8fa067a813d65fa32336714 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:30:54 -0400 Subject: [PATCH 29/82] replace the section bar buttons with a string select The eight sections no longer fit a five-button row, forcing a two-page split with a pager. A string select holds all of them in one row: the active section is the menu's default option (so it shows as the current value), and the paging plus the nav override query param are gone. navBar no longer needs the current path or query params, so its callers pass only the active section. Co-Authored-By: Claude Opus 4.8 --- src/routes/about/get.ts | 6 +- src/routes/commands/get.ts | 6 +- src/routes/delete-data/get.ts | 6 +- src/routes/filter/[scope]/get.ts | 6 +- src/routes/get.ts | 6 +- src/routes/lib/components.ts | 90 +++++++++++----------------- src/routes/mode/get.ts | 6 +- src/routes/recipients/[scope]/get.ts | 6 +- src/routes/ring/get.ts | 12 +--- src/routes/signups/get.ts | 6 +- src/routes/signups/roles/_shared.ts | 8 +-- 11 files changed, 45 insertions(+), 113 deletions(-) diff --git a/src/routes/about/get.ts b/src/routes/about/get.ts index 1b696f6..1f896c2 100644 --- a/src/routes/about/get.ts +++ b/src/routes/about/get.ts @@ -32,11 +32,7 @@ export const aboutGet: Handler<"GET"> = (router, interaction, state) => { .setStyle(ButtonStyle.Link) .setURL(SUPPORT_URL), ), - navBar(router, interaction, { - active: "about", - path: "/about", - queryParams: state.queryParams, - }), + navBar(router, interaction, { active: "about" }), ], }; }; diff --git a/src/routes/commands/get.ts b/src/routes/commands/get.ts index 7d94be0..9455dd5 100644 --- a/src/routes/commands/get.ts +++ b/src/routes/commands/get.ts @@ -50,11 +50,7 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { .setDescription(description), ], components: [ - navBar(router, interaction, { - active: "commands", - path: "/commands", - queryParams: state.queryParams, - }), + navBar(router, interaction, { active: "commands" }), ], }; }; diff --git a/src/routes/delete-data/get.ts b/src/routes/delete-data/get.ts index 26b44e6..953391c 100644 --- a/src/routes/delete-data/get.ts +++ b/src/routes/delete-data/get.ts @@ -33,11 +33,7 @@ export const deleteDataGet: Handler<"GET"> = (router, interaction, state) => { .setDisabled(done) .setTo(PANEL, { method: "MODAL" }), ), - navBar(router, interaction, { - active: "delete", - path: PANEL, - queryParams: state.queryParams, - }), + navBar(router, interaction, { active: "delete" }), ], }; }; diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index e480458..58ed945 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -101,11 +101,7 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { .setStyle(ButtonStyle.Danger) .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), ), - navBar(router, interaction, { - active: "filters", - path: panelPath(scope), - queryParams: state.queryParams, - }), + navBar(router, interaction, { active: "filters" }), ...paginationRows(router, panelPath(scope), { page, pageCount }), ], }; diff --git a/src/routes/get.ts b/src/routes/get.ts index c74bd28..267346e 100644 --- a/src/routes/get.ts +++ b/src/routes/get.ts @@ -22,11 +22,7 @@ export const homeGet: Handler<"GET"> = (router, interaction, state) => { return { embeds: [embed], components: [ - navBar(router, interaction, { - active: "home", - path: "/", - queryParams: state.queryParams, - }), + navBar(router, interaction, { active: "home" }), ], }; }; diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 12049f2..e204af2 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -1,7 +1,11 @@ import { buttonEmoji, VC_EMOJI_ID } from "@routes/lib/emoji"; import { Page } from "@routes/lib/paging"; import { RingButton, RingRouter } from "@routes/types"; -import { RouteButtonBuilder } from "discord-embed-router"; +import { + RouteButtonBuilder, + RouteStringSelectMenuBuilder, + RouteStringSelectMenuOptionBuilder, +} from "discord-embed-router"; import { ActionRowBuilder, APIActionRowComponent, @@ -32,8 +36,9 @@ export const backButton = ( .setStyle(ButtonStyle.Secondary) .setTo(path); -// the top-level section a panel belongs to, so the section bar can render it -// as the active tab. Panels with no section (Ring, error states) pass nothing +// the top-level section a panel belongs to, so the section bar can mark it as +// the current selection. Panels with no section (Ring quick-panel, error +// states) pass nothing, and the menu shows its placeholder instead export type Section = | "home" | "signups" @@ -46,84 +51,57 @@ export type Section = type Tab = { section: Section; label: string; path: string }; -// the eight section tabs split across two pages of four; a pager fills each -// row's fifth slot, so the two pages together reach every section -const PAGE_ONE: readonly Tab[] = [ +// every section in one menu; a string select holds all of them, so there is +// no five-button row cap to page around +const SECTIONS: readonly Tab[] = [ { section: "home", label: "🏠 Home", path: "/" }, { section: "signups", label: "🔔 Signups", path: "/signups" }, { section: "filters", label: "🛡️ Filters", path: "/filter/global" }, { section: "ringees", label: "📣 Ring", path: "/recipients/global" }, -]; - -const PAGE_TWO: readonly Tab[] = [ { section: "mode", label: "💤 Mode", path: "/mode" }, { section: "commands", label: "📖 Commands", path: "/commands" }, { section: "about", label: "ℹ️ About", path: "/about" }, { section: "delete", label: "🗑️ Delete data", path: "/delete-data" }, ]; -// the persistent section bar every panel ends on. The active section is a -// Primary tab, inert only on its own root so that on a sub-page or another -// scope it stays a live link back to that root. The Signups tab carries the -// branded voice-chat emoji when the bot can use it, else a unicode bell +// the persistent section bar every panel ends on, as a string select so all +// sections fit one row. The active section is the menu's default option, so it +// shows as the current value; reselecting it just reloads that section. The +// Signups option carries the branded voice-chat emoji when the bot can use it, +// else a unicode bell export const navBar = ( router: RingRouter, interaction: Interaction, - { - active, - path, - queryParams, - }: { active?: Section; path: string; queryParams: URLSearchParams }, + { active }: { active?: Section }, ): APIActionRowComponent => { - // open on the page holding the active section so the current tab is always - // visible; the pager's nav param overrides that to peek at the other page - // without leaving the panel - const override = queryParams.get("nav"); - const onPageTwo = - override === "2" || - (override !== "1" && PAGE_TWO.some((t) => t.section === active)); const vc = buttonEmoji(interaction, VC_EMOJI_ID); // the Ring section lands on the immediate ring action when the user is in a - // voice channel, and on the default-recipients settings otherwise, so the - // tab never opens the "not in a voice channel" notice by default + // voice channel, and on the default-recipients settings otherwise, so it + // never opens the "not in a voice channel" notice by default const inVoice = !!( interaction.member && "voice" in interaction.member && interaction.member.voice.channel ); - const tab = ({ section, label, path: to }: Tab): RingButton => { - const target = section === "ringees" && inVoice ? "/ring" : to; - const button = new RouteButtonBuilder(router) - // the active tab is Primary, except Delete data reads as its - // destructive Danger red when it is the one selected - .setStyle( - section !== active - ? ButtonStyle.Secondary - : section === "delete" - ? ButtonStyle.Danger - : ButtonStyle.Primary, - ) - .setDisabled(section === active && target === path) - .setTo(target); - if (section === "signups" && vc) button.setLabel("Signups").setEmoji(vc); - else button.setLabel(label); - return button; + const option = ({ section, label, path }: Tab) => { + const target = section === "ringees" && inVoice ? "/ring" : path; + const builder = new RouteStringSelectMenuOptionBuilder(router) + .setTo(target) + .setDefault(section === active); + if (section === "signups" && vc) builder.setLabel("Signups").setEmoji(vc); + else builder.setLabel(label); + return builder; }; - // the pager reloads the current panel showing the other page of tabs - const pager = (label: string, page: "1" | "2"): RingButton => - new RouteButtonBuilder(router) - .setLabel(label) - .setStyle(ButtonStyle.Secondary) - .setTo(path, { queryParams: { nav: page } }); + const select = new RouteStringSelectMenuBuilder(router) + .setPlaceholder("Jump to a section") + .setPattern("/") + .setTos(SECTIONS.map(option)); - const tabs = (onPageTwo ? PAGE_TWO : PAGE_ONE).map(tab); - return row( - ...(onPageTwo - ? [pager("◀ Back", "1"), ...tabs] - : [...tabs, pager("More ▶", "2")]), - ); + return new ActionRowBuilder() + .addComponents(select) + .toJSON(); }; // the sub-view switch a section shows just above the bar when it has sibling diff --git a/src/routes/mode/get.ts b/src/routes/mode/get.ts index 7c4ee71..ff02877 100644 --- a/src/routes/mode/get.ts +++ b/src/routes/mode/get.ts @@ -44,11 +44,7 @@ export const modeGet: Handler<"GET"> = async (router, interaction, state) => { .setTo(PATH, { method: "POST", queryParams: { set: mode } }), ), ), - navBar(router, interaction, { - active: "mode", - path: PATH, - queryParams: state.queryParams, - }), + navBar(router, interaction, { active: "mode" }), ], }; }; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 03ded15..fb5ac49 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -157,11 +157,7 @@ export const recipientsGet: Handler<"GET"> = async ( { label: "Quick ring", path: "/ring" }, { label: "Default ringees", path: panelPath(scope), active: true }, ]), - navBar(router, interaction, { - active: "ringees", - path: panelPath(scope), - queryParams: state.queryParams, - }), + navBar(router, interaction, { active: "ringees" }), ], }; }; diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index b18b71f..07ecbb8 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -33,11 +33,7 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { ], components: [ ringViews, - navBar(router, interaction, { - active: "ringees", - path: PANEL, - queryParams: state.queryParams, - }), + navBar(router, interaction, { active: "ringees" }), ], }; @@ -72,11 +68,7 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { .setTo(`${PANEL}/default`, { method: "POST" }), ), ringViews, - navBar(router, interaction, { - active: "ringees", - path: PANEL, - queryParams: state.queryParams, - }), + navBar(router, interaction, { active: "ringees" }), ], }; }; diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index 4ac8724..f48aea5 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -77,11 +77,7 @@ export const signupsGet: Handler<"GET"> = async ( ]), ] : []), - navBar(router, interaction, { - active: "signups", - path: PANEL, - queryParams: state.queryParams, - }), + navBar(router, interaction, { active: "signups" }), ], }; }; diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index 4a8610f..7ed1cf6 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -156,13 +156,7 @@ export const renderRoleScope = ({ { label: "By channel", path: BY_CHANNEL, active: active === "channel" }, { label: "By role", path: BY_ROLE, active: active === "role" }, ]), - navBar(router, interaction, { - active: "signups", - path: - (active === "channel" ? BY_CHANNEL : BY_ROLE) + - (scope ? `/${scope}` : ""), - queryParams, - }), + navBar(router, interaction, { active: "signups" }), ], }; }; From f016cf654963e3209c82a8cc81b37709039c76dc Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:18:10 -0400 Subject: [PATCH 30/82] route section select submissions to the picked option The select's customId needs the builder's default "*to" pattern so the selected option's encoded target fills the wildcard; setting an explicit "/" pattern dropped the selection and sent every submission to home. Home itself still needs an upstream fix: its "/" target encodes to an empty option value, which the router can't route back. Co-Authored-By: Claude Opus 4.8 --- src/routes/get.ts | 4 +--- src/routes/lib/components.ts | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/routes/get.ts b/src/routes/get.ts index 267346e..aeea1d6 100644 --- a/src/routes/get.ts +++ b/src/routes/get.ts @@ -21,8 +21,6 @@ export const homeGet: Handler<"GET"> = (router, interaction, state) => { return { embeds: [embed], - components: [ - navBar(router, interaction, { active: "home" }), - ], + components: [navBar(router, interaction, { active: "home" })], }; }; diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index e204af2..947648f 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -94,9 +94,10 @@ export const navBar = ( return builder; }; + // each option carries its own encoded target, and the builder's default + // pattern routes the submission to the selected one const select = new RouteStringSelectMenuBuilder(router) .setPlaceholder("Jump to a section") - .setPattern("/") .setTos(SECTIONS.map(option)); return new ActionRowBuilder() From b3bb4bebff2f2ebeae79c202e4ad65f4441f4de0 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:18:10 -0400 Subject: [PATCH 31/82] point /help at the command list and add /ringvc for the home panel /help now opens the /commands page (with a short getting-started intro added) instead of the bare home panel, and /ringvc opens the home panel. The command list's panel openers are updated to match. Co-Authored-By: Claude Opus 4.8 --- src/commands/commandNames.ts | 1 + src/commands/commands.ts | 2 ++ src/commands/help.ts | 4 ++-- src/commands/ringvc.ts | 17 +++++++++++++++++ src/routes/commands/get.ts | 12 ++++++------ 5 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 src/commands/ringvc.ts diff --git a/src/commands/commandNames.ts b/src/commands/commandNames.ts index 7d5bfd0..856a275 100644 --- a/src/commands/commandNames.ts +++ b/src/commands/commandNames.ts @@ -1,4 +1,5 @@ export const commandNamesList = [ + "ringvc", "help", "catalog", "delete_data", diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 8b8f5d7..51ee554 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -14,6 +14,7 @@ import { mode } from "@commands/mode"; import { quit } from "@commands/quit"; import { ring } from "@commands/ring"; import { ringDefaults } from "@commands/ringDefaults"; +import { ringvc } from "@commands/ringvc"; import { signup } from "@commands/signup"; import { signuprole } from "@commands/signuprole"; import { unsignup } from "@commands/unsignup"; @@ -30,6 +31,7 @@ export type CommandImplementation = { }; export const commands: CommandImplementation[] = [ + ringvc, help, catalog, deleteData, diff --git a/src/commands/help.ts b/src/commands/help.ts index cdc4318..6a49cb1 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -8,9 +8,9 @@ import { export const help = { data: new SlashCommandBuilder() .setName("help") - .setDescription("Getting started"), + .setDescription("Getting started and the full command list"), async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { - await router.dispatch(interaction, "/", { + await router.dispatch(interaction, "/commands", { flags: [MessageFlags.Ephemeral], }); }, diff --git a/src/commands/ringvc.ts b/src/commands/ringvc.ts new file mode 100644 index 0000000..eb9cbc3 --- /dev/null +++ b/src/commands/ringvc.ts @@ -0,0 +1,17 @@ +import { RingRouter } from "@routes/types"; +import { + ChatInputCommandInteraction, + MessageFlags, + SlashCommandBuilder, +} from "discord.js"; + +export const ringvc = { + data: new SlashCommandBuilder() + .setName("ringvc") + .setDescription("Open the RingVC home panel"), + async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { + await router.dispatch(interaction, "/", { + flags: [MessageFlags.Ephemeral], + }); + }, +}; diff --git a/src/routes/commands/get.ts b/src/routes/commands/get.ts index 9455dd5..c3ac3d2 100644 --- a/src/routes/commands/get.ts +++ b/src/routes/commands/get.ts @@ -24,8 +24,8 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { ["ring_defaults", "ring your saved defaults into your voice channel"], ]; const panels: [CommandName, string][] = [ - ["help", "the home panel"], - ["catalog", "this command list"], + ["ringvc", "the home panel"], + ["help", "this help page"], ["signup", "your signups panel (bare, outside a voice channel)"], ["filter", "your filter panel"], ["default_ring_recipients", "ring recipients and auto-ring panel"], @@ -38,7 +38,9 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { const description = withFlash( state.queryParams, - `**Quick actions**\n${list(quickActions)}\n\n` + + "Sign up for a voice channel to get pinged when someone starts a call " + + "there. Run a command below, or use the section menu to open any panel." + + `\n\n**Quick actions**\n${list(quickActions)}\n\n` + `**Panel openers**\n${list(panels)}`, ); @@ -49,8 +51,6 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { .setTitle("📖 Commands") .setDescription(description), ], - components: [ - navBar(router, interaction, { active: "commands" }), - ], + components: [navBar(router, interaction, { active: "commands" })], }; }; From a2136241664ff691d6f1d869f6f1f183f47b4a0a Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:18:26 -0400 Subject: [PATCH 32/82] update discord embed router --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index c9c591b..ab01b0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "dependencies": { "@supabase/supabase-js": "^2.110.2", - "discord-embed-router": "^1.2.0", + "discord-embed-router": "^1.2.1", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", @@ -2946,9 +2946,9 @@ ] }, "node_modules/discord-embed-router": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.2.0.tgz", - "integrity": "sha512-r01bpZYN7xuydAKxIIYYed0JpygBCZgCL8Zky17eaBcfpkPEFlhuQjz5WFvxysk7eGfGalXx/oIEPu5c3RC1vw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.2.1.tgz", + "integrity": "sha512-SNs3TfuE4A03vFJSKZFaQRLIJ2JJ1lsC7vv0zp9+KnoWdYY7aObogLvpSaLvIu0p4GpPFQON9GccMCfwsAFKOQ==", "license": "MIT", "workspaces": [ "examples/basic-bot" diff --git a/package.json b/package.json index 7d4f5e0..23b1f67 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@supabase/supabase-js": "^2.110.2", - "discord-embed-router": "^1.2.0", + "discord-embed-router": "^1.2.1", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", From 7acc25513d17ce2f77fa859eb6f46173398b5d2d Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:48:26 -0400 Subject: [PATCH 33/82] replace the role signups orientation toggle with dual scope selects Role signups open on a neutral view with a voice-channel select and a role select; the first one picked sets the orientation, and clearing the scope returns to it. This drops the By channel / By role button row, freeing space to restore the My signups / Role signups switch on every role view. Order the oriented rows low-to-high level top to bottom: the edit select and its pager sit above the scope select, which is the higher-level context the items hang off. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/index.ts | 17 ++-- src/routes/signups/roles/_shared.ts | 111 ++++++++++++++------- src/routes/signups/roles/by-channel/get.ts | 48 ++++----- src/routes/signups/roles/by-role/get.ts | 57 +++++------ src/routes/signups/roles/get.ts | 50 ++++++++++ 5 files changed, 181 insertions(+), 102 deletions(-) create mode 100644 src/routes/signups/roles/get.ts diff --git a/src/routes/index.ts b/src/routes/index.ts index 8f33787..0c345e8 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -24,6 +24,7 @@ import { ringUserPost } from "./ring/user/post"; import { ringUsersPost } from "./ring/users/post"; import { signupsGet } from "./signups/get"; import { signupsMembersPost } from "./signups/members/post"; +import { rolesGet } from "./signups/roles/get"; import { rolesByChannelGet } from "./signups/roles/by-channel/get"; import { rolesByChannelEditPost } from "./signups/roles/by-channel/roles/post"; import { rolesByRoleEditPost } from "./signups/roles/by-role/channels/post"; @@ -60,22 +61,20 @@ export const registerRoutes = (router: RingRouter) => { router.get("/signups", signupsGet); router.post("/signups/members", signupsMembersPost); - // role signups are a scoped panel like filter: an orientation segment - // (by-channel / by-role) then the scope id. The bare path lands on the - // by-channel view with nothing picked yet + // role signups open on a neutral view offering a channel select and a role + // select; picking either sets the orientation and routes to its scoped view. + // Clearing a scope drops back to the bare orientation path, which is neutral router.get( [ "/signups/roles", "/signups/roles/by-channel", - "/signups/roles/by-channel/:scope", + "/signups/roles/by-role", ], - rolesByChannelGet, + rolesGet, ); + router.get("/signups/roles/by-channel/:scope", rolesByChannelGet); router.post("/signups/roles/by-channel/:scope/roles", rolesByChannelEditPost); - router.get( - ["/signups/roles/by-role", "/signups/roles/by-role/:scope"], - rolesByRoleGet, - ); + router.get("/signups/roles/by-role/:scope", rolesByRoleGet); router.post("/signups/roles/by-role/:scope/channels", rolesByRoleEditPost); router.get("/ring", ringGet); diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index 7ed1cf6..dd5ccbb 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -13,13 +13,11 @@ import { import { joinWithAnd } from "@main/ring"; -import { COLOR } from "../_shared"; +import { COLOR, PANEL, ROLES } from "../_shared"; export const BY_CHANNEL = "/signups/roles/by-channel"; export const BY_ROLE = "/signups/roles/by-role"; -export type Orientation = "channel" | "role"; - // the shared lead sentence; both orientations describe the same feature const LEAD = "When someone joins a voice channel, its signed-up roles get pinged."; @@ -98,20 +96,74 @@ export const commitRoleEdit = async ({ }); }; -// assembles either orientation from its orientation-specific pieces: the -// caller builds the scope and edit selects (their menu types differ) and -// supplies the scope's current links; the frame, copy, and rows are shared +// the frame every role-signups view ends in. Rows read low-to-high level top to +// bottom: the caller's content rows, then the My signups / Role signups switch, +// then the section bar +const roleFrame = ({ + router, + interaction, + queryParams, + body, + rows, +}: { + router: RingRouter; + interaction: Interaction; + queryParams: URLSearchParams; + body: string; + rows: APIActionRowComponent[]; +}) => ({ + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("🔔 Role signups") + .setDescription(withFlash(queryParams, body)), + ], + components: [ + ...rows, + subNav(router, [ + { label: "My signups", path: PANEL }, + { label: "Role signups", path: ROLES, active: true }, + ]), + navBar(router, interaction, { active: "signups" }), + ], +}); + +// the entry view with nothing picked: a channel select and a role select. The +// first one the user touches sets the orientation; clearing that scope later +// returns here +export const renderRoleNeutral = ({ + router, + interaction, + queryParams, + channelSelectRow, + roleSelectRow, +}: { + router: RingRouter; + interaction: Interaction; + queryParams: URLSearchParams; + channelSelectRow: APIActionRowComponent; + roleSelectRow: APIActionRowComponent; +}) => + roleFrame({ + router, + interaction, + queryParams, + body: `${LEAD}\n\nPick a voice channel or a role above to view and edit its signups.`, + rows: [channelSelectRow, roleSelectRow], + }); + +// an oriented view: one scope is picked (a channel or a role) and its linked +// items are editable. The caller builds the scope and edit selects, since their +// menu types differ; the frame, copy, and rows are shared export const renderRoleScope = ({ router, interaction, queryParams, - active, scope, scopeMention, linkedLabel, linkedItems, itemMention, - emptyHint, scopeSelectRow, editSelectRow, basePath, @@ -121,42 +173,31 @@ export const renderRoleScope = ({ router: RingRouter; interaction: Interaction; queryParams: URLSearchParams; - active: Orientation; - scope: string | null; + scope: string; scopeMention: (id: string) => string; linkedLabel: string; linkedItems: string[]; itemMention: (id: string) => string; - emptyHint: string; scopeSelectRow: APIActionRowComponent; - // present only when a scope is picked; the empty view shows no edit select - editSelectRow?: - APIActionRowComponent | undefined; + editSelectRow: APIActionRowComponent; basePath: string; } & Pick) => { const linkedList = linkedItems.length > 0 ? linkedItems.map(itemMention).join(" ") : "None"; - const body = scope - ? `${LEAD}\n\n**Viewing** ${scopeMention(scope)}\n` + - `**${linkedLabel}${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${linkedList}` - : `${LEAD}\n\n${emptyHint}`; - - return { - embeds: [ - new EmbedBuilder() - .setColor(COLOR) - .setTitle("🔔 Role signups") - .setDescription(withFlash(queryParams, body)), - ], - components: [ - scopeSelectRow, - ...(scope && editSelectRow ? [editSelectRow] : []), + const body = + `${LEAD}\n\n**Viewing** ${scopeMention(scope)}\n` + + `**${linkedLabel}${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${linkedList}`; + return roleFrame({ + router, + interaction, + queryParams, + body, + // the scope select sits below its edit list and pager: it is the higher- + // level context the items hang off + rows: [ + editSelectRow, ...paginationRows(router, basePath, { page, pageCount }), - subNav(router, [ - { label: "By channel", path: BY_CHANNEL, active: active === "channel" }, - { label: "By role", path: BY_ROLE, active: active === "role" }, - ]), - navBar(router, interaction, { active: "signups" }), + scopeSelectRow, ], - }; + }); }; diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index 3f64bdf..5883ee3 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -16,6 +16,7 @@ import { roleScopeOf, sortRoleIds, } from "../_shared"; +import { rolesGet } from "../get"; import { canManageRoleSignups, guildOnlyRender, @@ -34,9 +35,12 @@ export const rolesByChannelGet: Handler<"GET"> = async ( if (!canManageRoleSignups(interaction)) return noPermissionRender(router); const scope = roleScopeOf(state.params); - const roleIds = scope - ? sortRoleIds(guild, (await getVoiceChatSignups(scope)).roleIds) - : []; + if (!scope) return rolesGet(router, interaction, state); + + const roleIds = sortRoleIds( + guild, + (await getVoiceChatSignups(scope)).roleIds, + ); const { pageItems, page, pageCount } = paginate( roleIds, state.queryParams.get("page"), @@ -48,43 +52,35 @@ export const rolesByChannelGet: Handler<"GET"> = async ( .setChannelTypes(ChannelType.GuildVoice) .setMinValues(0) .setMaxValues(1) - .setPlaceholder( - scope - ? "Viewing a channel's roles (clear to pick another)" - : "View a voice channel's roles", - ) - .setDefaultChannels(...(scope ? [scope] : [])) + .setPlaceholder("Viewing a voice channel's roles (clear to pick another)") + .setDefaultChannels(scope) .setPattern(`${BY_CHANNEL}{/:channelId}`), ) .toJSON(); - const editSelectRow = scope - ? new ActionRowBuilder() - .addComponents( - new RouteRoleSelectMenuBuilder(router) - .setMinValues(0) - .setMaxValues(PAGE_SIZE) - .setPlaceholder("Edit roles: select to add, deselect to remove") - .setDefaultRoles(...pageItems) - .setPattern(`${BY_CHANNEL}/${scope}/roles`, { - method: "POST", - queryParams: { page: String(page) }, - }), - ) - .toJSON() - : undefined; + const editSelectRow = new ActionRowBuilder() + .addComponents( + new RouteRoleSelectMenuBuilder(router) + .setMinValues(0) + .setMaxValues(PAGE_SIZE) + .setPlaceholder("Edit roles: select to add, deselect to remove") + .setDefaultRoles(...pageItems) + .setPattern(`${BY_CHANNEL}/${scope}/roles`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON(); return renderRoleScope({ router, interaction, queryParams: state.queryParams, - active: "channel", scope, scopeMention: mentionChannel, linkedLabel: "Roles pinged here", linkedItems: pageItems, itemMention: mentionRole, - emptyHint: "Pick a channel above to view or edit its roles.", scopeSelectRow, editSelectRow, basePath: `${BY_CHANNEL}/${scope}`, diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index 12447b6..6b4c403 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -16,6 +16,7 @@ import { roleScopeOf, sortChannelIds, } from "../_shared"; +import { rolesGet } from "../get"; import { canManageRoleSignups, guildOnlyRender, @@ -35,14 +36,14 @@ export const rolesByRoleGet: Handler<"GET"> = async ( if (!canManageRoleSignups(interaction)) return noPermissionRender(router); const scope = roleScopeOf(state.params); - const channelIds = scope - ? sortChannelIds( - guild, - (await getVoiceChatRoleSignups(guildVoiceChannelIds(guild))) - .filter((mapping) => mapping.roleId === scope) - .map((mapping) => mapping.channelId), - ) - : []; + if (!scope) return rolesGet(router, interaction, state); + + const channelIds = sortChannelIds( + guild, + (await getVoiceChatRoleSignups(guildVoiceChannelIds(guild))) + .filter((mapping) => mapping.roleId === scope) + .map((mapping) => mapping.channelId), + ); const { pageItems, page, pageCount } = paginate( channelIds, state.queryParams.get("page"), @@ -53,44 +54,36 @@ export const rolesByRoleGet: Handler<"GET"> = async ( new RouteRoleSelectMenuBuilder(router) .setMinValues(0) .setMaxValues(1) - .setPlaceholder( - scope - ? "Viewing a role's channels (clear to pick another)" - : "View a role's channels", - ) - .setDefaultRoles(...(scope ? [scope] : [])) + .setPlaceholder("Viewing a role's voice channels (clear to pick another)") + .setDefaultRoles(scope) .setPattern(`${BY_ROLE}{/:roleId}`), ) .toJSON(); - const editSelectRow = scope - ? new ActionRowBuilder() - .addComponents( - new RouteChannelSelectMenuBuilder(router) - .setChannelTypes(ChannelType.GuildVoice) - .setMinValues(0) - .setMaxValues(PAGE_SIZE) - .setPlaceholder("Edit channels: select to add, deselect to remove") - .setDefaultChannels(...pageItems) - .setPattern(`${BY_ROLE}/${scope}/channels`, { - method: "POST", - queryParams: { page: String(page) }, - }), - ) - .toJSON() - : undefined; + const editSelectRow = new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(PAGE_SIZE) + .setPlaceholder("Edit voice channels: select to add, deselect to remove") + .setDefaultChannels(...pageItems) + .setPattern(`${BY_ROLE}/${scope}/channels`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON(); return renderRoleScope({ router, interaction, queryParams: state.queryParams, - active: "role", scope, scopeMention: mentionRole, linkedLabel: "Signed up for", linkedItems: pageItems, itemMention: mentionChannel, - emptyHint: "Pick a role above to view or edit its channels.", scopeSelectRow, editSelectRow, basePath: `${BY_ROLE}/${scope}`, diff --git a/src/routes/signups/roles/get.ts b/src/routes/signups/roles/get.ts new file mode 100644 index 0000000..3231f10 --- /dev/null +++ b/src/routes/signups/roles/get.ts @@ -0,0 +1,50 @@ +import { homeButton, row } from "@routes/lib/components"; +import { Handler } from "@routes/types"; +import { + RouteChannelSelectMenuBuilder, + RouteRoleSelectMenuBuilder, +} from "discord-embed-router"; +import { ActionRowBuilder, ChannelType } from "discord.js"; + +import { BY_CHANNEL, BY_ROLE, renderRoleNeutral } from "./_shared"; +import { + canManageRoleSignups, + guildOnlyRender, + noPermissionRender, +} from "../_shared"; + +export const rolesGet: Handler<"GET"> = async (router, interaction, state) => { + const guild = interaction.guild; + if (!guild) + return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!canManageRoleSignups(interaction)) return noPermissionRender(router); + + const channelSelectRow = new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder("View a voice channel's roles") + .setPattern(`${BY_CHANNEL}{/:channelId}`), + ) + .toJSON(); + + const roleSelectRow = new ActionRowBuilder() + .addComponents( + new RouteRoleSelectMenuBuilder(router) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder("View a role's voice channels") + .setPattern(`${BY_ROLE}{/:roleId}`), + ) + .toJSON(); + + return renderRoleNeutral({ + router, + interaction, + queryParams: state.queryParams, + channelSelectRow, + roleSelectRow, + }); +}; From cff55e985627ecfab7cb534afe0f58789a6860fc Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:12:47 -0400 Subject: [PATCH 34/82] lay panels out top-to-bottom with global nav pinned at the bottom Scope select leads each panel as its context; the edit list and its pager follow, then the panel-wide actions, then the sub-nav and section bar at the bottom. Applies to filter, ring, and role signups. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/filter/[scope]/get.ts | 101 ++++++++++--------- src/routes/recipients/[scope]/get.ts | 141 ++++++++++++++------------- src/routes/signups/roles/_shared.ts | 6 +- 3 files changed, 130 insertions(+), 118 deletions(-) diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index 58ed945..b9ac592 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -44,6 +44,52 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { `\n\n**Members${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${memberList}`, ); + const scopeSelect = new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder( + channelId + ? "Viewing a channel filter (clear for global)" + : "View a voice channel's filter", + ) + .setDefaultChannels(...(channelId ? [channelId] : [])) + .setPattern("/filter{/:channelId}"), + ) + .toJSON(); + + const editSelect = new ActionRowBuilder() + .addComponents( + new RouteUserSelectMenuBuilder(router) + .setMinValues(0) + .setMaxValues(PAGE_SIZE) + .setPlaceholder("Edit members: select to add, deselect to remove") + .setDefaultUsers(...pageItems) + .setPattern(`${panelPath(scope)}/members`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON(); + + const filterActions = row( + new RouteButtonBuilder(router) + .setLabel( + type === "blacklist" ? "Switch to Whitelist" : "Switch to Blacklist", + ) + .setStyle(ButtonStyle.Primary) + .setTo(`${panelPath(scope)}/type`, { + method: "POST", + queryParams: { to: type === "blacklist" ? "whitelist" : "blacklist" }, + }), + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Danger) + .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), + ); + return { embeds: [ new EmbedBuilder() @@ -53,56 +99,15 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { ) .setDescription(description), ], + // top to bottom within the page: the scope select leads as context, then + // the member list with its pager and the filter-wide actions; the section + // bar is the global nav pinned at the bottom components: [ - new ActionRowBuilder() - .addComponents( - new RouteChannelSelectMenuBuilder(router) - .setChannelTypes(ChannelType.GuildVoice) - .setMinValues(0) - .setMaxValues(1) - .setPlaceholder( - channelId - ? "Viewing a channel filter (clear for global)" - : "View a voice channel's filter", - ) - .setDefaultChannels(...(channelId ? [channelId] : [])) - .setPattern("/filter{/:channelId}"), - ) - .toJSON(), - new ActionRowBuilder() - .addComponents( - new RouteUserSelectMenuBuilder(router) - .setMinValues(0) - .setMaxValues(PAGE_SIZE) - .setPlaceholder("Edit members: select to add, deselect to remove") - .setDefaultUsers(...pageItems) - .setPattern(`${panelPath(scope)}/members`, { - method: "POST", - queryParams: { page: String(page) }, - }), - ) - .toJSON(), - row( - new RouteButtonBuilder(router) - .setLabel( - type === "blacklist" - ? "Switch to Whitelist" - : "Switch to Blacklist", - ) - .setStyle(ButtonStyle.Primary) - .setTo(`${panelPath(scope)}/type`, { - method: "POST", - queryParams: { - to: type === "blacklist" ? "whitelist" : "blacklist", - }, - }), - new RouteButtonBuilder(router) - .setLabel("Reset") - .setStyle(ButtonStyle.Danger) - .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), - ), - navBar(router, interaction, { active: "filters" }), + scopeSelect, + editSelect, ...paginationRows(router, panelPath(scope), { page, pageCount }), + filterActions, + navBar(router, interaction, { active: "filters" }), ], }; }; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index fb5ac49..3b2429d 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -76,6 +76,74 @@ export const recipientsGet: Handler<"GET"> = async ( `**Ringees${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${memberList}`, ); + const scopeSelect = new ActionRowBuilder() + .addComponents( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setMinValues(0) + .setMaxValues(1) + .setPlaceholder( + channelId + ? "Viewing channel ringees (clear for global)" + : "View a voice channel's ringees", + ) + .setDefaultChannels(...(channelId ? [channelId] : [])) + .setPattern("/recipients{/:channelId}"), + ) + .toJSON(); + + const editSelect = new ActionRowBuilder() + .addComponents( + new RouteUserSelectMenuBuilder(router) + .setMinValues(0) + .setMaxValues(PAGE_SIZE) + .setPlaceholder("Edit ringees: select to add, deselect to remove") + .setDefaultUsers(...pageItems) + .setPattern(`${panelPath(scope)}/members`, { + method: "POST", + queryParams: { page: String(page) }, + }), + ) + .toJSON(); + + const ringActions = row( + // prev/next page the recipient list inline, so this dense panel + // spends no separate row on pagination + ...(page > 0 + ? [ + new RouteButtonBuilder(router) + .setLabel("◀") + .setStyle(ButtonStyle.Secondary) + .setTo(panelPath(scope), { + queryParams: { page: String(page - 1) }, + }), + ] + : []), + new RouteButtonBuilder(router) + .setLabel(autoRing.effective ? "Disable auto-ring" : "Enable auto-ring") + .setStyle( + autoRing.effective ? ButtonStyle.Secondary : ButtonStyle.Success, + ) + .setTo(`${panelPath(scope)}/auto-ring`, { + method: "POST", + queryParams: { enable: autoRing.effective ? "0" : "1" }, + }), + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Danger) + .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), + ...(page < pageCount - 1 + ? [ + new RouteButtonBuilder(router) + .setLabel("▶") + .setStyle(ButtonStyle.Secondary) + .setTo(panelPath(scope), { + queryParams: { page: String(page + 1) }, + }), + ] + : []), + ); + return { embeds: [ new EmbedBuilder() @@ -85,74 +153,13 @@ export const recipientsGet: Handler<"GET"> = async ( ) .setDescription(description), ], + // top to bottom within the page: the scope select leads as context, then + // the ringee list and the actions (with the inline pager); the sub-nav and + // section bar are navigation pinned at the bottom components: [ - new ActionRowBuilder() - .addComponents( - new RouteChannelSelectMenuBuilder(router) - .setChannelTypes(ChannelType.GuildVoice) - .setMinValues(0) - .setMaxValues(1) - .setPlaceholder( - channelId - ? "Viewing channel ringees (clear for global)" - : "View a voice channel's ringees", - ) - .setDefaultChannels(...(channelId ? [channelId] : [])) - .setPattern("/recipients{/:channelId}"), - ) - .toJSON(), - new ActionRowBuilder() - .addComponents( - new RouteUserSelectMenuBuilder(router) - .setMinValues(0) - .setMaxValues(PAGE_SIZE) - .setPlaceholder("Edit ringees: select to add, deselect to remove") - .setDefaultUsers(...pageItems) - .setPattern(`${panelPath(scope)}/members`, { - method: "POST", - queryParams: { page: String(page) }, - }), - ) - .toJSON(), - row( - // prev/next page the recipient list inline, so this dense panel - // spends no separate row on pagination - ...(page > 0 - ? [ - new RouteButtonBuilder(router) - .setLabel("◀") - .setStyle(ButtonStyle.Secondary) - .setTo(panelPath(scope), { - queryParams: { page: String(page - 1) }, - }), - ] - : []), - new RouteButtonBuilder(router) - .setLabel( - autoRing.effective ? "Disable auto-ring" : "Enable auto-ring", - ) - .setStyle( - autoRing.effective ? ButtonStyle.Secondary : ButtonStyle.Success, - ) - .setTo(`${panelPath(scope)}/auto-ring`, { - method: "POST", - queryParams: { enable: autoRing.effective ? "0" : "1" }, - }), - new RouteButtonBuilder(router) - .setLabel("Reset") - .setStyle(ButtonStyle.Danger) - .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), - ...(page < pageCount - 1 - ? [ - new RouteButtonBuilder(router) - .setLabel("▶") - .setStyle(ButtonStyle.Secondary) - .setTo(panelPath(scope), { - queryParams: { page: String(page + 1) }, - }), - ] - : []), - ), + scopeSelect, + editSelect, + ringActions, subNav(router, [ { label: "Quick ring", path: "/ring" }, { label: "Default ringees", path: panelPath(scope), active: true }, diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index dd5ccbb..6499db7 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -192,12 +192,12 @@ export const renderRoleScope = ({ interaction, queryParams, body, - // the scope select sits below its edit list and pager: it is the higher- - // level context the items hang off + // the scope select leads as the page's context, then its edit list and + // pager; the switch and section bar follow in the frame rows: [ + scopeSelectRow, editSelectRow, ...paginationRows(router, basePath, { page, pageCount }), - scopeSelectRow, ], }); }; From bd618e7d328f986c7c93aae2ae0940100be0f18d Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:18:54 -0400 Subject: [PATCH 35/82] refactor the commands page into a Help section with a sub-nav Rename the section to Help and split it into a Getting started guide and a Catalog reference, switched by a sub-nav. Getting started walks through signups, role signups, ringing, filters, and modes with inline command mentions; Catalog keeps the full command list. /help opens Getting started, /catalog opens the Catalog. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/catalog.ts | 2 +- src/commands/help.ts | 2 +- src/routes/help/_shared.ts | 12 +++++++ src/routes/{commands => help/catalog}/get.ts | 17 +++++---- src/routes/help/get.ts | 37 ++++++++++++++++++++ src/routes/index.ts | 6 ++-- src/routes/lib/components.ts | 4 +-- 7 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 src/routes/help/_shared.ts rename src/routes/{commands => help/catalog}/get.ts (80%) create mode 100644 src/routes/help/get.ts diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 685115c..5036193 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -10,7 +10,7 @@ export const catalog = { .setName("catalog") .setDescription("List every RingVC command"), async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { - await router.dispatch(interaction, "/commands", { + await router.dispatch(interaction, "/help/catalog", { flags: [MessageFlags.Ephemeral], }); }, diff --git a/src/commands/help.ts b/src/commands/help.ts index 6a49cb1..ebe535a 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -10,7 +10,7 @@ export const help = { .setName("help") .setDescription("Getting started and the full command list"), async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { - await router.dispatch(interaction, "/commands", { + await router.dispatch(interaction, "/help", { flags: [MessageFlags.Ephemeral], }); }, diff --git a/src/routes/help/_shared.ts b/src/routes/help/_shared.ts new file mode 100644 index 0000000..0d630d4 --- /dev/null +++ b/src/routes/help/_shared.ts @@ -0,0 +1,12 @@ +import { subNav } from "@routes/lib/components"; +import { RingRouter } from "@routes/types"; + +export const COLOR = "#6197cd"; +export const HELP = "/help"; +export const CATALOG = "/help/catalog"; + +export const helpSubNav = (router: RingRouter, active: "help" | "catalog") => + subNav(router, [ + { label: "Getting started", path: HELP, active: active === "help" }, + { label: "Catalog", path: CATALOG, active: active === "catalog" }, + ]); diff --git a/src/routes/commands/get.ts b/src/routes/help/catalog/get.ts similarity index 80% rename from src/routes/commands/get.ts rename to src/routes/help/catalog/get.ts index c3ac3d2..9a5ad00 100644 --- a/src/routes/commands/get.ts +++ b/src/routes/help/catalog/get.ts @@ -6,9 +6,9 @@ import { EmbedBuilder } from "discord.js"; import { CommandName } from "@commands/commandNames"; -const COLOR = "#6197cd"; +import { COLOR, helpSubNav } from "../_shared"; -export const commandsGet: Handler<"GET"> = (router, interaction, state) => { +export const catalogGet: Handler<"GET"> = (router, interaction, state) => { const mention = (name: CommandName) => commandMention(state.globals, name); const quickActions: [CommandName, string][] = [ ["signup", "sign up for a voice channel (bare, in a VC's text chat)"], @@ -25,7 +25,8 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { ]; const panels: [CommandName, string][] = [ ["ringvc", "the home panel"], - ["help", "this help page"], + ["help", "getting started"], + ["catalog", "this command catalog"], ["signup", "your signups panel (bare, outside a voice channel)"], ["filter", "your filter panel"], ["default_ring_recipients", "ring recipients and auto-ring panel"], @@ -38,8 +39,7 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { const description = withFlash( state.queryParams, - "Sign up for a voice channel to get pinged when someone starts a call " + - "there. Run a command below, or use the section menu to open any panel." + + "Every RingVC command. Run one below, or use the section menu to open any panel." + `\n\n**Quick actions**\n${list(quickActions)}\n\n` + `**Panel openers**\n${list(panels)}`, ); @@ -48,9 +48,12 @@ export const commandsGet: Handler<"GET"> = (router, interaction, state) => { embeds: [ new EmbedBuilder() .setColor(COLOR) - .setTitle("📖 Commands") + .setTitle("📖 Catalog") .setDescription(description), ], - components: [navBar(router, interaction, { active: "commands" })], + components: [ + helpSubNav(router, "catalog"), + navBar(router, interaction, { active: "help" }), + ], }; }; diff --git a/src/routes/help/get.ts b/src/routes/help/get.ts new file mode 100644 index 0000000..e8d40d4 --- /dev/null +++ b/src/routes/help/get.ts @@ -0,0 +1,37 @@ +import { navBar } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; +import { Handler } from "@routes/types"; +import { EmbedBuilder } from "discord.js"; + +import { CommandName } from "@commands/commandNames"; + +import { COLOR, helpSubNav } from "./_shared"; + +export const helpGet: Handler<"GET"> = (router, interaction, state) => { + const mention = (name: CommandName) => commandMention(state.globals, name); + + const description = withFlash( + state.queryParams, + "RingVC replicates group-chat voice calls in Discord servers: sign up for a voice channel and get pinged when someone starts a call there.\n\n" + + `**Sign up** · ${mention("signup")} in a voice channel's text chat, or open the Signups panel to manage several at once.\n` + + `**Role signups** · with Manage Roles, sign a whole role up for a channel via ${mention("signuprole")} or the Role signups view.\n` + + `**Ring people in** · ${mention("ring")} pulls someone into your call; save defaults for ${mention("ring_defaults")}, or turn on auto-ring to ping them on every join.\n` + + `**Filters** · ${mention("block")} and ${mention("whitelist")} control who can ring you.\n` + + `**Modes** · Normal rings everyone, Stealth rings nobody, Auto goes stealth while you're invisible — set it with ${mention("mode")}.\n\n` + + "-# Full command list on the Catalog tab. Jump to any panel with the section menu below.", + ); + + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setTitle("📖 Getting started") + .setDescription(description), + ], + components: [ + helpSubNav(router, "help"), + navBar(router, interaction, { active: "help" }), + ], + }; +}; diff --git a/src/routes/index.ts b/src/routes/index.ts index 0c345e8..1bea472 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,7 +1,8 @@ import { RingRouter } from "@routes/types"; import { aboutGet } from "./about/get"; -import { commandsGet } from "./commands/get"; +import { helpGet } from "./help/get"; +import { catalogGet } from "./help/catalog/get"; import { deleteDataGet } from "./delete-data/get"; import { deleteDataModal } from "./delete-data/modal"; import { deleteDataPost } from "./delete-data/post"; @@ -34,7 +35,8 @@ import { rolesByRoleGet } from "./signups/roles/by-role/get"; // [param] segments) and the file is the method export const registerRoutes = (router: RingRouter) => { router.get("/", homeGet); - router.get("/commands", commandsGet); + router.get("/help", helpGet); + router.get("/help/catalog", catalogGet); router.get("/about", aboutGet); // scoped panels answer their bare path as the global scope, so their diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 947648f..f3bcc86 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -45,7 +45,7 @@ export type Section = | "filters" | "ringees" | "mode" - | "commands" + | "help" | "about" | "delete"; @@ -59,7 +59,7 @@ const SECTIONS: readonly Tab[] = [ { section: "filters", label: "🛡️ Filters", path: "/filter/global" }, { section: "ringees", label: "📣 Ring", path: "/recipients/global" }, { section: "mode", label: "💤 Mode", path: "/mode" }, - { section: "commands", label: "📖 Commands", path: "/commands" }, + { section: "help", label: "📖 Help", path: "/help" }, { section: "about", label: "ℹ️ About", path: "/about" }, { section: "delete", label: "🗑️ Delete data", path: "/delete-data" }, ]; From 46fcba034440ace6fdeb8777a82ef3db192f8802 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:19:34 -0400 Subject: [PATCH 36/82] apply prettier formatting Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/index.ts | 12 ++++-------- src/routes/signups/roles/by-channel/get.ts | 6 ++++-- src/routes/signups/roles/by-role/get.ts | 10 +++++++--- src/routes/signups/roles/get.ts | 2 +- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/routes/index.ts b/src/routes/index.ts index 1bea472..9904552 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,8 +1,6 @@ import { RingRouter } from "@routes/types"; import { aboutGet } from "./about/get"; -import { helpGet } from "./help/get"; -import { catalogGet } from "./help/catalog/get"; import { deleteDataGet } from "./delete-data/get"; import { deleteDataModal } from "./delete-data/modal"; import { deleteDataPost } from "./delete-data/post"; @@ -12,6 +10,8 @@ import { filterResetModal } from "./filter/[scope]/reset/modal"; import { filterResetPost } from "./filter/[scope]/reset/post"; import { filterTypePost } from "./filter/[scope]/type/post"; import { homeGet } from "./get"; +import { catalogGet } from "./help/catalog/get"; +import { helpGet } from "./help/get"; import { modeGet } from "./mode/get"; import { modePost } from "./mode/post"; import { recipientsAutoRingPost } from "./recipients/[scope]/auto-ring/post"; @@ -25,11 +25,11 @@ import { ringUserPost } from "./ring/user/post"; import { ringUsersPost } from "./ring/users/post"; import { signupsGet } from "./signups/get"; import { signupsMembersPost } from "./signups/members/post"; -import { rolesGet } from "./signups/roles/get"; import { rolesByChannelGet } from "./signups/roles/by-channel/get"; import { rolesByChannelEditPost } from "./signups/roles/by-channel/roles/post"; import { rolesByRoleEditPost } from "./signups/roles/by-role/channels/post"; import { rolesByRoleGet } from "./signups/roles/by-role/get"; +import { rolesGet } from "./signups/roles/get"; // handlers live in files mirroring their route: the folder is the path (with // [param] segments) and the file is the method @@ -67,11 +67,7 @@ export const registerRoutes = (router: RingRouter) => { // select; picking either sets the orientation and routes to its scoped view. // Clearing a scope drops back to the bare orientation path, which is neutral router.get( - [ - "/signups/roles", - "/signups/roles/by-channel", - "/signups/roles/by-role", - ], + ["/signups/roles", "/signups/roles/by-channel", "/signups/roles/by-role"], rolesGet, ); router.get("/signups/roles/by-channel/:scope", rolesByChannelGet); diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index 5883ee3..d00ef62 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -16,13 +16,13 @@ import { roleScopeOf, sortRoleIds, } from "../_shared"; -import { rolesGet } from "../get"; import { canManageRoleSignups, guildOnlyRender, mentionChannel, noPermissionRender, } from "../../_shared"; +import { rolesGet } from "../get"; export const rolesByChannelGet: Handler<"GET"> = async ( router, @@ -52,7 +52,9 @@ export const rolesByChannelGet: Handler<"GET"> = async ( .setChannelTypes(ChannelType.GuildVoice) .setMinValues(0) .setMaxValues(1) - .setPlaceholder("Viewing a voice channel's roles (clear to pick another)") + .setPlaceholder( + "Viewing a voice channel's roles (clear to pick another)", + ) .setDefaultChannels(scope) .setPattern(`${BY_CHANNEL}{/:channelId}`), ) diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index 6b4c403..b9a7b96 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -16,7 +16,6 @@ import { roleScopeOf, sortChannelIds, } from "../_shared"; -import { rolesGet } from "../get"; import { canManageRoleSignups, guildOnlyRender, @@ -24,6 +23,7 @@ import { mentionChannel, noPermissionRender, } from "../../_shared"; +import { rolesGet } from "../get"; export const rolesByRoleGet: Handler<"GET"> = async ( router, @@ -54,7 +54,9 @@ export const rolesByRoleGet: Handler<"GET"> = async ( new RouteRoleSelectMenuBuilder(router) .setMinValues(0) .setMaxValues(1) - .setPlaceholder("Viewing a role's voice channels (clear to pick another)") + .setPlaceholder( + "Viewing a role's voice channels (clear to pick another)", + ) .setDefaultRoles(scope) .setPattern(`${BY_ROLE}{/:roleId}`), ) @@ -66,7 +68,9 @@ export const rolesByRoleGet: Handler<"GET"> = async ( .setChannelTypes(ChannelType.GuildVoice) .setMinValues(0) .setMaxValues(PAGE_SIZE) - .setPlaceholder("Edit voice channels: select to add, deselect to remove") + .setPlaceholder( + "Edit voice channels: select to add, deselect to remove", + ) .setDefaultChannels(...pageItems) .setPattern(`${BY_ROLE}/${scope}/channels`, { method: "POST", diff --git a/src/routes/signups/roles/get.ts b/src/routes/signups/roles/get.ts index 3231f10..223b006 100644 --- a/src/routes/signups/roles/get.ts +++ b/src/routes/signups/roles/get.ts @@ -6,12 +6,12 @@ import { } from "discord-embed-router"; import { ActionRowBuilder, ChannelType } from "discord.js"; -import { BY_CHANNEL, BY_ROLE, renderRoleNeutral } from "./_shared"; import { canManageRoleSignups, guildOnlyRender, noPermissionRender, } from "../_shared"; +import { BY_CHANNEL, BY_ROLE, renderRoleNeutral } from "./_shared"; export const rolesGet: Handler<"GET"> = async (router, interaction, state) => { const guild = interaction.guild; From 25c2eda1a1df2863c2a8bf51116c08f0681f63a6 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:21:44 -0400 Subject: [PATCH 37/82] fix getting-started copy to accurate ringing terminology Ringing pings someone to join your voice channel; drop the invented "pulls someone into your call". Lead with Ring, and align each blurb with the behavior described in the README. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/help/get.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/routes/help/get.ts b/src/routes/help/get.ts index e8d40d4..73de0e4 100644 --- a/src/routes/help/get.ts +++ b/src/routes/help/get.ts @@ -13,12 +13,12 @@ export const helpGet: Handler<"GET"> = (router, interaction, state) => { const description = withFlash( state.queryParams, - "RingVC replicates group-chat voice calls in Discord servers: sign up for a voice channel and get pinged when someone starts a call there.\n\n" + - `**Sign up** · ${mention("signup")} in a voice channel's text chat, or open the Signups panel to manage several at once.\n` + - `**Role signups** · with Manage Roles, sign a whole role up for a channel via ${mention("signuprole")} or the Role signups view.\n` + - `**Ring people in** · ${mention("ring")} pulls someone into your call; save defaults for ${mention("ring_defaults")}, or turn on auto-ring to ping them on every join.\n` + - `**Filters** · ${mention("block")} and ${mention("whitelist")} control who can ring you.\n` + - `**Modes** · Normal rings everyone, Stealth rings nobody, Auto goes stealth while you're invisible — set it with ${mention("mode")}.\n\n` + + "RingVC replicates group-chat calls in Discord servers. A server can't ring people the way a group chat does, so RingVC pings them in the voice channel's text chat instead.\n\n" + + `**Ring** · ${mention("ring")} pings someone to join the voice channel you're in. Save default people and ping them with ${mention("ring_defaults")}, or turn on auto-ring to ping them automatically whenever you start a call.\n` + + `**Sign up** · ${mention("signup")} in a voice channel's text chat signs you up, so you get pinged when someone starts a call there (joins it while it's empty). Manage several from the Signups panel.\n` + + `**Role signups** · with Manage Roles, sign a whole role up for a channel via ${mention("signuprole")} so its members get pinged.\n` + + `**Filters** · ${mention("block")} stops someone from ringing you, and ${mention("whitelist")} restricts ringing to only the people you list.\n` + + `**Modes** · Normal pings your signed-up people when you start a call, Stealth pings no one, Auto goes stealth only while you're invisible — set it with ${mention("mode")}.\n\n` + "-# Full command list on the Catalog tab. Jump to any panel with the section menu below.", ); From 1c609191e1e5e61d375a9c20c0851a22e11f7730 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:23:13 -0400 Subject: [PATCH 38/82] drop em dash from the modes blurb Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/help/get.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/help/get.ts b/src/routes/help/get.ts index 73de0e4..9d4c6e8 100644 --- a/src/routes/help/get.ts +++ b/src/routes/help/get.ts @@ -18,7 +18,7 @@ export const helpGet: Handler<"GET"> = (router, interaction, state) => { `**Sign up** · ${mention("signup")} in a voice channel's text chat signs you up, so you get pinged when someone starts a call there (joins it while it's empty). Manage several from the Signups panel.\n` + `**Role signups** · with Manage Roles, sign a whole role up for a channel via ${mention("signuprole")} so its members get pinged.\n` + `**Filters** · ${mention("block")} stops someone from ringing you, and ${mention("whitelist")} restricts ringing to only the people you list.\n` + - `**Modes** · Normal pings your signed-up people when you start a call, Stealth pings no one, Auto goes stealth only while you're invisible — set it with ${mention("mode")}.\n\n` + + `**Modes** · Normal pings your signed-up people when you start a call, Stealth pings no one, Auto goes stealth only while you're invisible. Set it with ${mention("mode")}.\n\n` + "-# Full command list on the Catalog tab. Jump to any panel with the section menu below.", ); From 9721a7780dda2dcd94e1d9dcb7ff391150239e4c Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:27:06 -0400 Subject: [PATCH 39/82] show the role signups guidance only to Manage Roles members Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/help/get.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/routes/help/get.ts b/src/routes/help/get.ts index 9d4c6e8..0988be6 100644 --- a/src/routes/help/get.ts +++ b/src/routes/help/get.ts @@ -6,19 +6,30 @@ import { EmbedBuilder } from "discord.js"; import { CommandName } from "@commands/commandNames"; +import { canManageRoleSignups } from "../signups/_shared"; import { COLOR, helpSubNav } from "./_shared"; export const helpGet: Handler<"GET"> = (router, interaction, state) => { const mention = (name: CommandName) => commandMention(state.globals, name); + const lines = [ + `**Ring** · ${mention("ring")} pings someone to join the voice channel you're in. Save default people and ping them with ${mention("ring_defaults")}, or turn on auto-ring to ping them automatically whenever you start a call.`, + `**Sign up** · ${mention("signup")} in a voice channel's text chat signs you up, so you get pinged when someone starts a call there (joins it while it's empty). Manage several from the Signups panel.`, + // role signups are a Manage Roles action, so the guidance only shows to + // members who can use it + ...(canManageRoleSignups(interaction) + ? [ + `**Role signups** · with Manage Roles, sign a whole role up for a channel via ${mention("signuprole")} so its members get pinged.`, + ] + : []), + `**Filters** · ${mention("block")} stops someone from ringing you, and ${mention("whitelist")} restricts ringing to only the people you list.`, + `**Modes** · Normal pings your signed-up people when you start a call, Stealth pings no one, Auto goes stealth only while you're invisible. Set it with ${mention("mode")}.`, + ]; + const description = withFlash( state.queryParams, "RingVC replicates group-chat calls in Discord servers. A server can't ring people the way a group chat does, so RingVC pings them in the voice channel's text chat instead.\n\n" + - `**Ring** · ${mention("ring")} pings someone to join the voice channel you're in. Save default people and ping them with ${mention("ring_defaults")}, or turn on auto-ring to ping them automatically whenever you start a call.\n` + - `**Sign up** · ${mention("signup")} in a voice channel's text chat signs you up, so you get pinged when someone starts a call there (joins it while it's empty). Manage several from the Signups panel.\n` + - `**Role signups** · with Manage Roles, sign a whole role up for a channel via ${mention("signuprole")} so its members get pinged.\n` + - `**Filters** · ${mention("block")} stops someone from ringing you, and ${mention("whitelist")} restricts ringing to only the people you list.\n` + - `**Modes** · Normal pings your signed-up people when you start a call, Stealth pings no one, Auto goes stealth only while you're invisible. Set it with ${mention("mode")}.\n\n` + + `${lines.join("\n")}\n\n` + "-# Full command list on the Catalog tab. Jump to any panel with the section menu below.", ); From 174149c0736da2c3ba0b814658c39fe383803122 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:29:08 -0400 Subject: [PATCH 40/82] group the catalog commands by feature Split the flat quick-action list into Ringing, Signups, Role signups, and Filters groups, with the panel openers following. The Manage Roles note moves to the Role signups header. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/help/catalog/get.ts | 83 +++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/src/routes/help/catalog/get.ts b/src/routes/help/catalog/get.ts index 9a5ad00..762c593 100644 --- a/src/routes/help/catalog/get.ts +++ b/src/routes/help/catalog/get.ts @@ -10,38 +10,67 @@ import { COLOR, helpSubNav } from "../_shared"; export const catalogGet: Handler<"GET"> = (router, interaction, state) => { const mention = (name: CommandName) => commandMention(state.globals, name); - const quickActions: [CommandName, string][] = [ - ["signup", "sign up for a voice channel (bare, in a VC's text chat)"], - ["unsignup", "remove a signup"], - ["quit", "same as /unsignup"], - ["signuprole", "sign up a role for a channel (Manage Roles)"], - ["unsignuprole", "remove a role signup (Manage Roles)"], - ["block", "block someone from ringing you"], - ["unblock", "unblock them"], - ["whitelist", "allow someone through your whitelist"], - ["unwhitelist", "remove them from your whitelist"], - ["ring", "ring someone into your voice channel (with a user)"], - ["ring_defaults", "ring your saved defaults into your voice channel"], + const groups: { title: string; entries: [CommandName, string][] }[] = [ + { + title: "Ringing", + entries: [ + ["ring", "ring someone into your voice channel (with a user)"], + ["ring_defaults", "ring your saved defaults into your voice channel"], + ], + }, + { + title: "Signups", + entries: [ + ["signup", "sign up for a voice channel (bare, in a VC's text chat)"], + ["unsignup", "remove a signup"], + ["quit", "same as /unsignup"], + ], + }, + { + title: "Role signups (Manage Roles)", + entries: [ + ["signuprole", "sign up a role for a channel"], + ["unsignuprole", "remove a role signup"], + ], + }, + { + title: "Filters", + entries: [ + ["block", "block someone from ringing you"], + ["unblock", "unblock them"], + ["whitelist", "allow someone through your whitelist"], + ["unwhitelist", "remove them from your whitelist"], + ], + }, + { + title: "Panels", + entries: [ + ["ringvc", "the home panel"], + ["help", "getting started"], + ["catalog", "this command catalog"], + ["signup", "your signups panel (bare, outside a voice channel)"], + ["filter", "your filter panel"], + ["default_ring_recipients", "ring recipients and auto-ring panel"], + ["mode", "mode panel"], + ["ring", "ring panel (bare)"], + ["delete_data", "data deletion panel"], + ], + }, ]; - const panels: [CommandName, string][] = [ - ["ringvc", "the home panel"], - ["help", "getting started"], - ["catalog", "this command catalog"], - ["signup", "your signups panel (bare, outside a voice channel)"], - ["filter", "your filter panel"], - ["default_ring_recipients", "ring recipients and auto-ring panel"], - ["mode", "mode panel"], - ["ring", "ring panel (bare)"], - ["delete_data", "data deletion panel"], - ]; - const list = (entries: [CommandName, string][]) => + const renderGroup = ({ + title, + entries, + }: { + title: string; + entries: [CommandName, string][]; + }) => + `**${title}**\n` + entries.map(([name, blurb]) => `${mention(name)} · ${blurb}`).join("\n"); const description = withFlash( state.queryParams, - "Every RingVC command. Run one below, or use the section menu to open any panel." + - `\n\n**Quick actions**\n${list(quickActions)}\n\n` + - `**Panel openers**\n${list(panels)}`, + "Every RingVC command. Run one below, or use the section menu to open any panel.\n\n" + + groups.map(renderGroup).join("\n\n"), ); return { From 3c5617d12732eee4c730bf9cc6d42bbd37c25a1c Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:33:45 -0400 Subject: [PATCH 41/82] gate the catalog role signups group to Manage Roles members Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/help/catalog/get.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/routes/help/catalog/get.ts b/src/routes/help/catalog/get.ts index 762c593..406eb4f 100644 --- a/src/routes/help/catalog/get.ts +++ b/src/routes/help/catalog/get.ts @@ -7,6 +7,7 @@ import { EmbedBuilder } from "discord.js"; import { CommandName } from "@commands/commandNames"; import { COLOR, helpSubNav } from "../_shared"; +import { canManageRoleSignups } from "../../signups/_shared"; export const catalogGet: Handler<"GET"> = (router, interaction, state) => { const mention = (name: CommandName) => commandMention(state.globals, name); @@ -26,13 +27,19 @@ export const catalogGet: Handler<"GET"> = (router, interaction, state) => { ["quit", "same as /unsignup"], ], }, - { - title: "Role signups (Manage Roles)", - entries: [ - ["signuprole", "sign up a role for a channel"], - ["unsignuprole", "remove a role signup"], - ], - }, + // role signups are a Manage Roles action, so the group only shows to + // members who can use it + ...(canManageRoleSignups(interaction) + ? [ + { + title: "Role signups (Manage Roles)", + entries: [ + ["signuprole", "sign up a role for a channel"], + ["unsignuprole", "remove a role signup"], + ] as [CommandName, string][], + }, + ] + : []), { title: "Filters", entries: [ From 8e9d437bf9ad11ff8fa3dbf880c8060f5fdbb013 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:34:22 -0400 Subject: [PATCH 42/82] describe the bare /ring as "without a user" in the catalog Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/help/catalog/get.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/help/catalog/get.ts b/src/routes/help/catalog/get.ts index 406eb4f..47517f9 100644 --- a/src/routes/help/catalog/get.ts +++ b/src/routes/help/catalog/get.ts @@ -59,7 +59,7 @@ export const catalogGet: Handler<"GET"> = (router, interaction, state) => { ["filter", "your filter panel"], ["default_ring_recipients", "ring recipients and auto-ring panel"], ["mode", "mode panel"], - ["ring", "ring panel (bare)"], + ["ring", "ring panel (without a user)"], ["delete_data", "data deletion panel"], ], }, From 103032fe55dd628d0cd0fd6eb7df93ff0dc5b9b6 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:41:09 -0400 Subject: [PATCH 43/82] shrink selection pages to 10 items Co-Authored-By: Claude Fable 5 --- src/routes/lib/paging.test.ts | 32 ++++++++++++++++---------------- src/routes/lib/paging.ts | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/routes/lib/paging.test.ts b/src/routes/lib/paging.test.ts index f8dd233..302abac 100644 --- a/src/routes/lib/paging.test.ts +++ b/src/routes/lib/paging.test.ts @@ -1,36 +1,36 @@ -import { diffSelection, paginate } from "@routes/lib/paging"; +import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; import { expect, test } from "vitest"; const ids = (count: number, offset = 0) => Array.from({ length: count }, (_, i) => `${i + offset + 1}`); test("a partial page fits one page with no pagination", () => { - expect(paginate(ids(24), null)).toStrictEqual({ - pageItems: ids(24), + expect(paginate(ids(PAGE_SIZE - 1), null)).toStrictEqual({ + pageItems: ids(PAGE_SIZE - 1), page: 0, pageCount: 1, }); }); test("an exactly full page is followed by an empty page for adding", () => { - expect(paginate(ids(25), "1")).toStrictEqual({ + expect(paginate(ids(PAGE_SIZE), "1")).toStrictEqual({ pageItems: [], page: 1, pageCount: 2, }); }); -test("lists over 25 split into pages of 25", () => { - const items = ids(30); +test("lists over the page size split into pages of PAGE_SIZE", () => { + const items = ids(PAGE_SIZE + 5); expect(paginate(items, "1")).toStrictEqual({ - pageItems: items.slice(25), + pageItems: items.slice(PAGE_SIZE), page: 1, pageCount: 2, }); }); test("a full last page is followed by an empty page for adding", () => { - const items = ids(50); + const items = ids(PAGE_SIZE * 2); expect(paginate(items, "2")).toStrictEqual({ pageItems: [], page: 2, @@ -39,17 +39,17 @@ test("a full last page is followed by an empty page for adding", () => { }); test("a stale page index clamps to the last page", () => { - const items = ids(30); + const items = ids(PAGE_SIZE + 5); expect(paginate(items, "7").page).toBe(1); }); test("garbage and negative page indexes fall back to the first page", () => { - expect(paginate(ids(30), "abc").page).toBe(0); - expect(paginate(ids(30), "-3").page).toBe(0); + expect(paginate(ids(PAGE_SIZE + 5), "abc").page).toBe(0); + expect(paginate(ids(PAGE_SIZE + 5), "-3").page).toBe(0); }); test("selecting new values adds them without touching other pages", () => { - const allItems = ids(30); + const allItems = ids(PAGE_SIZE + 5); const { pageItems } = paginate(allItems, "1"); expect( diffSelection({ allItems, pageItems, submitted: [...pageItems, "999"] }), @@ -57,7 +57,7 @@ test("selecting new values adds them without touching other pages", () => { }); test("deselecting page entries removes only them", () => { - const allItems = ids(30); + const allItems = ids(PAGE_SIZE + 5); const { pageItems } = paginate(allItems, "1"); expect( diffSelection({ allItems, pageItems, submitted: pageItems.slice(1) }), @@ -65,7 +65,7 @@ test("deselecting page entries removes only them", () => { }); test("adds and removes apply together from one submission", () => { - const allItems = ids(30); + const allItems = ids(PAGE_SIZE + 5); const { pageItems } = paginate(allItems, "1"); expect( diffSelection({ @@ -77,7 +77,7 @@ test("adds and removes apply together from one submission", () => { }); test("a submitted value that already lives on another page is not re-added", () => { - const allItems = ids(30); + const allItems = ids(PAGE_SIZE + 5); const { pageItems } = paginate(allItems, "1"); // "1" is on page 0; selecting it on page 1 must not report an add expect( @@ -86,7 +86,7 @@ test("a submitted value that already lives on another page is not re-added", () }); test("deselecting everything on a page removes the whole page only", () => { - const allItems = ids(30); + const allItems = ids(PAGE_SIZE + 5); const { pageItems } = paginate(allItems, "0"); expect(diffSelection({ allItems, pageItems, submitted: [] })).toStrictEqual({ added: [], diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts index 94f69fb..14b8c7b 100644 --- a/src/routes/lib/paging.ts +++ b/src/routes/lib/paging.ts @@ -1,5 +1,5 @@ // Discord's cap on select menu options and default values -export const PAGE_SIZE = 25; +export const PAGE_SIZE = 10; export type Page = { pageItems: string[]; From 160c30b35b7c9463348dc7eb348e32779d991e0e Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:42:25 -0400 Subject: [PATCH 44/82] let edit selects accept up to 25 values regardless of page size Co-Authored-By: Claude Fable 5 --- src/routes/filter/[scope]/get.ts | 4 ++-- src/routes/lib/paging.ts | 3 +++ src/routes/recipients/[scope]/get.ts | 4 ++-- src/routes/ring/get.ts | 4 ++-- src/routes/signups/get.ts | 4 ++-- src/routes/signups/roles/by-channel/get.ts | 4 ++-- src/routes/signups/roles/by-role/get.ts | 4 ++-- 7 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index b9ac592..e1b8b18 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -1,6 +1,6 @@ import { navBar, paginationRows, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; -import { PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { paginate, SELECT_MAX_VALUES } from "@routes/lib/paging"; import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; import { @@ -64,7 +64,7 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { .addComponents( new RouteUserSelectMenuBuilder(router) .setMinValues(0) - .setMaxValues(PAGE_SIZE) + .setMaxValues(SELECT_MAX_VALUES) .setPlaceholder("Edit members: select to add, deselect to remove") .setDefaultUsers(...pageItems) .setPattern(`${panelPath(scope)}/members`, { diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts index 14b8c7b..083bc7a 100644 --- a/src/routes/lib/paging.ts +++ b/src/routes/lib/paging.ts @@ -1,5 +1,8 @@ // Discord's cap on select menu options and default values export const PAGE_SIZE = 10; +// Discord's cap on select menu max_values; edit selects allow it in full so +// users can add more entries per submit than one page displays. +export const SELECT_MAX_VALUES = 25; export type Page = { pageItems: string[]; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 3b2429d..ebc67d1 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -1,7 +1,7 @@ import { navBar, row, subNav } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; -import { PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { paginate, SELECT_MAX_VALUES } from "@routes/lib/paging"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; import { @@ -96,7 +96,7 @@ export const recipientsGet: Handler<"GET"> = async ( .addComponents( new RouteUserSelectMenuBuilder(router) .setMinValues(0) - .setMaxValues(PAGE_SIZE) + .setMaxValues(SELECT_MAX_VALUES) .setPlaceholder("Edit ringees: select to add, deselect to remove") .setDefaultUsers(...pageItems) .setPattern(`${panelPath(scope)}/members`, { diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index 07ecbb8..278f54d 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -1,6 +1,6 @@ import { navBar, row, subNav } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; -import { PAGE_SIZE } from "@routes/lib/paging"; +import { SELECT_MAX_VALUES } from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { RouteButtonBuilder, @@ -56,7 +56,7 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { .addComponents( new RouteUserSelectMenuBuilder(router) .setMinValues(1) - .setMaxValues(PAGE_SIZE) + .setMaxValues(SELECT_MAX_VALUES) .setPlaceholder("Select up to 25 people to ring") .setPattern(`${PANEL}/users`, { method: "POST" }), ) diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index f48aea5..d48949d 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -6,7 +6,7 @@ import { subNav, } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; -import { PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { paginate, SELECT_MAX_VALUES } from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { RouteChannelSelectMenuBuilder } from "discord-embed-router"; import { ActionRowBuilder, ChannelType, EmbedBuilder } from "discord.js"; @@ -57,7 +57,7 @@ export const signupsGet: Handler<"GET"> = async ( new RouteChannelSelectMenuBuilder(router) .setChannelTypes(ChannelType.GuildVoice) .setMinValues(0) - .setMaxValues(PAGE_SIZE) + .setMaxValues(SELECT_MAX_VALUES) .setPlaceholder("Edit signups: select to add, deselect to remove") .setDefaultChannels(...pageItems) .setPattern(`${PANEL}/members`, { diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index d00ef62..9a0a532 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -1,5 +1,5 @@ import { homeButton, row } from "@routes/lib/components"; -import { PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { paginate, SELECT_MAX_VALUES } from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { RouteChannelSelectMenuBuilder, @@ -64,7 +64,7 @@ export const rolesByChannelGet: Handler<"GET"> = async ( .addComponents( new RouteRoleSelectMenuBuilder(router) .setMinValues(0) - .setMaxValues(PAGE_SIZE) + .setMaxValues(SELECT_MAX_VALUES) .setPlaceholder("Edit roles: select to add, deselect to remove") .setDefaultRoles(...pageItems) .setPattern(`${BY_CHANNEL}/${scope}/roles`, { diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index b9a7b96..60d09ce 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -1,5 +1,5 @@ import { homeButton, row } from "@routes/lib/components"; -import { PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { paginate, SELECT_MAX_VALUES } from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { RouteChannelSelectMenuBuilder, @@ -67,7 +67,7 @@ export const rolesByRoleGet: Handler<"GET"> = async ( new RouteChannelSelectMenuBuilder(router) .setChannelTypes(ChannelType.GuildVoice) .setMinValues(0) - .setMaxValues(PAGE_SIZE) + .setMaxValues(SELECT_MAX_VALUES) .setPlaceholder( "Edit voice channels: select to add, deselect to remove", ) From ee5f5c01e4b6b9036483e5ba4a2bb52795b514cb Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:51:15 -0400 Subject: [PATCH 45/82] flash the existing entry's page when a selection is already present Co-Authored-By: Claude Fable 5 --- src/routes/filter/[scope]/members/post.ts | 18 ++++++---- src/routes/lib/paging.test.ts | 27 ++++++++++++--- src/routes/lib/paging.ts | 33 +++++++++++++++---- src/routes/recipients/[scope]/members/post.ts | 22 ++++++++----- src/routes/signups/members/post.ts | 18 ++++++---- src/routes/signups/roles/_shared.ts | 24 +++++++++----- .../signups/roles/by-channel/roles/post.ts | 8 ++++- .../signups/roles/by-role/channels/post.ts | 8 ++++- 8 files changed, 117 insertions(+), 41 deletions(-) diff --git a/src/routes/filter/[scope]/members/post.ts b/src/routes/filter/[scope]/members/post.ts index a1cc3cb..08c1c3f 100644 --- a/src/routes/filter/[scope]/members/post.ts +++ b/src/routes/filter/[scope]/members/post.ts @@ -1,5 +1,5 @@ import { flashRedirect } from "@routes/lib/flash"; -import { resolveSelectionEdit } from "@routes/lib/paging"; +import { resolveSelectionEdit, withPageLabel } from "@routes/lib/paging"; import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; @@ -46,11 +46,12 @@ export const filterMembersPost: Handler<"POST"> = async ( ); const entries = [...(filter?.entries ?? [])].sort(); - const { addsRequested, removesRequested } = resolveSelectionEdit({ - current: entries, - values: state.values, - queryParams: query, - }); + const { addsRequested, removesRequested, alreadyPresent } = + resolveSelectionEdit({ + current: entries, + values: state.values, + queryParams: query, + }); const entrySet = new Set(entries); const toAdd = addsRequested.filter((id) => !entrySet.has(id)); @@ -89,6 +90,11 @@ export const filterMembersPost: Handler<"POST"> = async ( ...(toRemove.length > 0 ? [`Removed ${joinWithAnd(toRemove.map(mentionUser))}`] : []), + ...(alreadyPresent.length > 0 + ? [ + `${joinWithAnd(alreadyPresent.map(withPageLabel(entries, mentionUser)))} ${alreadyPresent.length > 1 ? "are" : "is"} already listed`, + ] + : []), ]; flash = parts.length > 0 diff --git a/src/routes/lib/paging.test.ts b/src/routes/lib/paging.test.ts index 302abac..18563b6 100644 --- a/src/routes/lib/paging.test.ts +++ b/src/routes/lib/paging.test.ts @@ -1,4 +1,9 @@ -import { diffSelection, PAGE_SIZE, paginate } from "@routes/lib/paging"; +import { + diffSelection, + PAGE_SIZE, + paginate, + withPageLabel, +} from "@routes/lib/paging"; import { expect, test } from "vitest"; const ids = (count: number, offset = 0) => @@ -53,7 +58,7 @@ test("selecting new values adds them without touching other pages", () => { const { pageItems } = paginate(allItems, "1"); expect( diffSelection({ allItems, pageItems, submitted: [...pageItems, "999"] }), - ).toStrictEqual({ added: ["999"], removed: [] }); + ).toStrictEqual({ added: ["999"], removed: [], alreadyPresent: [] }); }); test("deselecting page entries removes only them", () => { @@ -61,7 +66,7 @@ test("deselecting page entries removes only them", () => { const { pageItems } = paginate(allItems, "1"); expect( diffSelection({ allItems, pageItems, submitted: pageItems.slice(1) }), - ).toStrictEqual({ added: [], removed: [pageItems[0]] }); + ).toStrictEqual({ added: [], removed: [pageItems[0]], alreadyPresent: [] }); }); test("adds and removes apply together from one submission", () => { @@ -73,7 +78,11 @@ test("adds and removes apply together from one submission", () => { pageItems, submitted: [...pageItems.slice(1), "999"], }), - ).toStrictEqual({ added: ["999"], removed: [pageItems[0]] }); + ).toStrictEqual({ + added: ["999"], + removed: [pageItems[0]], + alreadyPresent: [], + }); }); test("a submitted value that already lives on another page is not re-added", () => { @@ -82,7 +91,7 @@ test("a submitted value that already lives on another page is not re-added", () // "1" is on page 0; selecting it on page 1 must not report an add expect( diffSelection({ allItems, pageItems, submitted: [...pageItems, "1"] }), - ).toStrictEqual({ added: [], removed: [] }); + ).toStrictEqual({ added: [], removed: [], alreadyPresent: ["1"] }); }); test("deselecting everything on a page removes the whole page only", () => { @@ -91,5 +100,13 @@ test("deselecting everything on a page removes the whole page only", () => { expect(diffSelection({ allItems, pageItems, submitted: [] })).toStrictEqual({ added: [], removed: pageItems, + alreadyPresent: [], }); }); + +test("withPageLabel points at the page an existing entry lives on", () => { + const allItems = ids(PAGE_SIZE * 2); + const label = withPageLabel(allItems, (id) => `#${id}`); + expect(label("1")).toBe("#1 (page 1)"); + expect(label(`${PAGE_SIZE + 1}`)).toBe(`#${PAGE_SIZE + 1} (page 2)`); +}); diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts index 083bc7a..01b770b 100644 --- a/src/routes/lib/paging.ts +++ b/src/routes/lib/paging.ts @@ -27,8 +27,9 @@ export const paginate = (items: string[], rawPage: string | null): Page => { // diffs a select submission against the visible page: entries missing from // the submission are removals, submitted values not in the whole list are -// additions (checking the whole list keeps pages independent: re-selecting -// a value that lives on another page is not an add) +// additions. Submitted values that live in the list but on another page are +// no-ops (pages stay independent) and are reported as `alreadyPresent` so +// flashes can point at the existing entry instead of claiming "no changes" export const diffSelection = ({ allItems, pageItems, @@ -37,15 +38,26 @@ export const diffSelection = ({ allItems: string[]; pageItems: string[]; submitted: string[]; -}): { added: string[]; removed: string[] } => { +}): { added: string[]; removed: string[]; alreadyPresent: string[] } => { const allSet = new Set(allItems); + const pageSet = new Set(pageItems); const submittedSet = new Set(submitted); return { added: submitted.filter((value) => !allSet.has(value)), removed: pageItems.filter((value) => !submittedSet.has(value)), + alreadyPresent: submitted.filter( + (value) => allSet.has(value) && !pageSet.has(value), + ), }; }; +// labels a value with the page that displays it, e.g. "#general (page 2)", +// so a flash can point at where an existing entry lives +export const withPageLabel = + (allItems: string[], mention: (id: string) => string) => + (value: string): string => + `${mention(value)} (page ${Math.floor(allItems.indexOf(value) / PAGE_SIZE) + 1})`; + // resolves an add/remove edit for a paged member select from either input a // handler can receive: a select submission (diffed against the visible page) // or `add`/`remove` query params from a command adapter. Callers still filter @@ -58,18 +70,27 @@ export const resolveSelectionEdit = ({ current: string[]; values: string[] | undefined; queryParams: URLSearchParams; -}): { addsRequested: string[]; removesRequested: string[] } => { +}): { + addsRequested: string[]; + removesRequested: string[]; + alreadyPresent: string[]; +} => { if (values) { const { pageItems } = paginate(current, queryParams.get("page")); - const { added, removed } = diffSelection({ + const { added, removed, alreadyPresent } = diffSelection({ allItems: current, pageItems, submitted: values, }); - return { addsRequested: added, removesRequested: removed }; + return { + addsRequested: added, + removesRequested: removed, + alreadyPresent, + }; } return { addsRequested: queryParams.getAll("add"), removesRequested: queryParams.getAll("remove"), + alreadyPresent: [], }; }; diff --git a/src/routes/recipients/[scope]/members/post.ts b/src/routes/recipients/[scope]/members/post.ts index 3614f81..4ae2056 100644 --- a/src/routes/recipients/[scope]/members/post.ts +++ b/src/routes/recipients/[scope]/members/post.ts @@ -1,5 +1,5 @@ import { flashRedirect } from "@routes/lib/flash"; -import { resolveSelectionEdit } from "@routes/lib/paging"; +import { resolveSelectionEdit, withPageLabel } from "@routes/lib/paging"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; @@ -23,11 +23,12 @@ export const recipientsMembersPost: Handler<"POST"> = async ( const panel = panelPath(scope); const ringees = [...(await getDefaultRingees(userId, channelId))].sort(); - const { addsRequested, removesRequested } = resolveSelectionEdit({ - current: ringees, - values: state.values, - queryParams: state.queryParams, - }); + const { addsRequested, removesRequested, alreadyPresent } = + resolveSelectionEdit({ + current: ringees, + values: state.values, + queryParams: state.queryParams, + }); const ringeeSet = new Set(ringees); const added = addsRequested.filter((id) => !ringeeSet.has(id)); const removed = removesRequested.filter((id) => ringeeSet.has(id)); @@ -44,11 +45,16 @@ export const recipientsMembersPost: Handler<"POST"> = async ( ...(removed.length > 0 ? [`Removed ${joinWithAnd(removed.map(mentionUser))}`] : []), + ...(alreadyPresent.length > 0 + ? [ + `${joinWithAnd(alreadyPresent.map(withPageLabel(ringees, mentionUser)))} ${alreadyPresent.length > 1 ? "are" : "is"} already added`, + ] + : []), ]; - const changed = parts.length > 0; + const changed = added.length > 0 || removed.length > 0; return flashRedirect( panel, - changed + parts.length > 0 ? `${parts.join(". ")} (default ring recipients ${scopeSuffix(scope)})` : `No changes to your default ring recipients ${scopeSuffix(scope)}`, changed ? "success" : "warn", diff --git a/src/routes/signups/members/post.ts b/src/routes/signups/members/post.ts index c42d8d7..b6edd2d 100644 --- a/src/routes/signups/members/post.ts +++ b/src/routes/signups/members/post.ts @@ -1,5 +1,5 @@ import { flashRedirect } from "@routes/lib/flash"; -import { resolveSelectionEdit } from "@routes/lib/paging"; +import { resolveSelectionEdit, withPageLabel } from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { addVoiceChatUser, removeVoiceChatUser } from "@db/voice-chats"; @@ -27,11 +27,12 @@ export const signupsMembersPost: Handler<"POST"> = async ( // only the select path needs the current signups to diff against; command // adapters pass the ids to add/remove directly, so skip the fetch for them const signups = state.values ? await guildSignups(userId, guild) : []; - const { addsRequested, removesRequested } = resolveSelectionEdit({ - current: signups, - values: state.values, - queryParams: query, - }); + const { addsRequested, removesRequested, alreadyPresent } = + resolveSelectionEdit({ + current: signups, + values: state.values, + queryParams: query, + }); const addResults = await Promise.all( addsRequested.map(async (channelId) => ({ @@ -64,6 +65,11 @@ export const signupsMembersPost: Handler<"POST"> = async ( `You are already signed up for ${joinWithAnd(alreadySignedUp.map(mentionChannel))}`, ] : []), + ...(alreadyPresent.length > 0 + ? [ + `You are already signed up for ${joinWithAnd(alreadyPresent.map(withPageLabel(signups, mentionChannel)))}`, + ] + : []), ...(removed.length > 0 ? [ `You will no longer be rung for ${joinWithAnd(removed.map(mentionChannel))}`, diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index 6499db7..bbb7322 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -1,6 +1,6 @@ import { navBar, paginationRows, subNav } from "@routes/lib/components"; import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { Page } from "@routes/lib/paging"; +import { Page, withPageLabel } from "@routes/lib/paging"; import { RingRouter } from "@routes/types"; import { RouteRedirect } from "discord-embed-router"; import { @@ -57,6 +57,7 @@ export const commitRoleEdit = async ({ current, addsRequested, removesRequested, + alreadyPresent = [], mutateAdd, mutateRemove, itemMention, @@ -66,6 +67,7 @@ export const commitRoleEdit = async ({ current: string[]; addsRequested: string[]; removesRequested: string[]; + alreadyPresent?: string[]; mutateAdd: (item: string) => Promise; mutateRemove: (item: string) => Promise; itemMention: (id: string) => string; @@ -82,15 +84,21 @@ export const commitRoleEdit = async ({ ...(toRemove.length > 0 ? [`Removed ${joinWithAnd(toRemove.map(itemMention))}`] : []), + ...(alreadyPresent.length > 0 + ? [ + `${joinWithAnd(alreadyPresent.map(withPageLabel(current, itemMention)))} ${alreadyPresent.length > 1 ? "are" : "is"} already signed up`, + ] + : []), ]; const changed = toAdd.length > 0 || toRemove.length > 0; - const flash = changed - ? parts.join(". ") - : addsRequested.length > 0 - ? "Already signed up." - : removesRequested.length > 0 - ? "Those weren't signed up." - : "No changes to role signups."; + const flash = + parts.length > 0 + ? parts.join(". ") + : addsRequested.length > 0 + ? "Already signed up." + : removesRequested.length > 0 + ? "Those weren't signed up." + : "No changes to role signups."; return flashRedirect(redirect, flash, changed ? "success" : "warn", { page: String(page), }); diff --git a/src/routes/signups/roles/by-channel/roles/post.ts b/src/routes/signups/roles/by-channel/roles/post.ts index 759643e..2cc0573 100644 --- a/src/routes/signups/roles/by-channel/roles/post.ts +++ b/src/routes/signups/roles/by-channel/roles/post.ts @@ -50,9 +50,14 @@ export const rolesByChannelEditPost: Handler<"POST"> = async ( let addsRequested: string[]; let removesRequested: string[]; + let alreadyPresent: string[] = []; if (state.values) { const { pageItems } = paginate(current, query.get("page")); - ({ added: addsRequested, removed: removesRequested } = diffSelection({ + ({ + added: addsRequested, + removed: removesRequested, + alreadyPresent, + } = diffSelection({ allItems: current, pageItems, submitted: state.values, @@ -68,6 +73,7 @@ export const rolesByChannelEditPost: Handler<"POST"> = async ( current, addsRequested, removesRequested, + alreadyPresent, mutateAdd: (roleId) => addVoiceChatRole(scope, roleId), mutateRemove: (roleId) => removeVoiceChatRole(scope, roleId), itemMention: mentionRole, diff --git a/src/routes/signups/roles/by-role/channels/post.ts b/src/routes/signups/roles/by-role/channels/post.ts index 03c7685..af58d80 100644 --- a/src/routes/signups/roles/by-role/channels/post.ts +++ b/src/routes/signups/roles/by-role/channels/post.ts @@ -56,9 +56,14 @@ export const rolesByRoleEditPost: Handler<"POST"> = async ( let addsRequested: string[]; let removesRequested: string[]; + let alreadyPresent: string[] = []; if (state.values) { const { pageItems } = paginate(current, query.get("page")); - ({ added: addsRequested, removed: removesRequested } = diffSelection({ + ({ + added: addsRequested, + removed: removesRequested, + alreadyPresent, + } = diffSelection({ allItems: current, pageItems, submitted: state.values, @@ -77,6 +82,7 @@ export const rolesByRoleEditPost: Handler<"POST"> = async ( current, addsRequested, removesRequested, + alreadyPresent, mutateAdd: (channelId) => addVoiceChatRole(channelId, scope), mutateRemove: (channelId) => removeVoiceChatRole(channelId, scope), itemMention: mentionChannel, From 28cd6eec60d7e377e8087b6be495aca65641300c Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:53:33 -0400 Subject: [PATCH 46/82] turn the page indicator button into a go-to-page modal Co-Authored-By: Claude Fable 5 --- src/routes/index.ts | 6 ++++++ src/routes/lib/components.ts | 15 ++++++++++++++- src/routes/page-jump/_shared.ts | 1 + src/routes/page-jump/modal.ts | 32 ++++++++++++++++++++++++++++++++ src/routes/page-jump/post.ts | 16 ++++++++++++++++ 5 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/routes/page-jump/_shared.ts create mode 100644 src/routes/page-jump/modal.ts create mode 100644 src/routes/page-jump/post.ts diff --git a/src/routes/index.ts b/src/routes/index.ts index 9904552..7cd9162 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -14,6 +14,8 @@ import { catalogGet } from "./help/catalog/get"; import { helpGet } from "./help/get"; import { modeGet } from "./mode/get"; import { modePost } from "./mode/post"; +import { pageJumpModal } from "./page-jump/modal"; +import { pageJumpPost } from "./page-jump/post"; import { recipientsAutoRingPost } from "./recipients/[scope]/auto-ring/post"; import { recipientsGet } from "./recipients/[scope]/get"; import { recipientsMembersPost } from "./recipients/[scope]/members/post"; @@ -60,6 +62,10 @@ export const registerRoutes = (router: RingRouter) => { router.route("/mode", { get: modeGet, post: modePost }); + // the shared page-jump modal every paginated panel's middle button opens; + // the originating panel rides along in the `to` query param + router.route("/page-jump", { modal: pageJumpModal, post: pageJumpPost }); + router.get("/signups", signupsGet); router.post("/signups/members", signupsMembersPost); diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index f3bcc86..be39d48 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -1,5 +1,6 @@ import { buttonEmoji, VC_EMOJI_ID } from "@routes/lib/emoji"; import { Page } from "@routes/lib/paging"; +import { PAGE_JUMP } from "@routes/page-jump/_shared"; import { RingButton, RingRouter } from "@routes/types"; import { RouteButtonBuilder, @@ -142,7 +143,19 @@ export const paginationRows = ( return [ row( pageButton("◀ Prev", page - 1, page === 0), - pageButton(`Page ${page + 1} of ${pageCount}`, page, true), + // the middle button doubles as a page jump: it opens a modal asking + // which page to show, which redirects back to basePath + new RouteButtonBuilder(router) + .setLabel(`Page ${page + 1} of ${pageCount}`) + .setStyle(ButtonStyle.Secondary) + .setTo(PAGE_JUMP, { + method: "MODAL", + queryParams: { + to: basePath, + page: String(page), + pageCount: String(pageCount), + }, + }), pageButton("Next ▶", page + 1, page === pageCount - 1), ), ]; diff --git a/src/routes/page-jump/_shared.ts b/src/routes/page-jump/_shared.ts new file mode 100644 index 0000000..6b63a39 --- /dev/null +++ b/src/routes/page-jump/_shared.ts @@ -0,0 +1 @@ +export const PAGE_JUMP = "/page-jump"; diff --git a/src/routes/page-jump/modal.ts b/src/routes/page-jump/modal.ts new file mode 100644 index 0000000..ca78265 --- /dev/null +++ b/src/routes/page-jump/modal.ts @@ -0,0 +1,32 @@ +import { Handler } from "@routes/types"; +import { RouteModalBuilder } from "discord-embed-router"; +import { LabelBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; + +import { PAGE_JUMP } from "./_shared"; + +// opened by the "Page n of m" pagination button of any paged panel; the +// button carries the panel's path in `to` so one modal serves them all +export const pageJumpModal: Handler<"MODAL"> = (router, interaction, state) => { + const query = state.queryParams; + const pageCount = query.get("pageCount") ?? ""; + return new RouteModalBuilder(router) + .setTo(PAGE_JUMP, { + method: "POST", + queryParams: { + to: query.get("to") ?? "/", + page: query.get("page") ?? "0", + }, + }) + .setTitle("Go to page") + .addLabelComponents( + new LabelBuilder() + .setLabel(`Page number (1–${pageCount})`) + .setTextInputComponent( + new TextInputBuilder() + .setCustomId("page") + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setPlaceholder(`1–${pageCount}`), + ), + ); +}; diff --git a/src/routes/page-jump/post.ts b/src/routes/page-jump/post.ts new file mode 100644 index 0000000..a113acd --- /dev/null +++ b/src/routes/page-jump/post.ts @@ -0,0 +1,16 @@ +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +export const pageJumpPost: Handler<"POST"> = (router, interaction, state) => { + const query = state.queryParams; + const to = query.get("to") ?? "/"; + const input = state.fields?.getTextInputValue("page") ?? ""; + const parsed = parseInt(input, 10); + // a bad entry stays on the page the modal was opened from; an oversized + // number needs no check because paginate clamps to the last page + if (isNaN(parsed) || parsed < 1) + return flashRedirect(to, `"${input}" is not a page number`, "warn", { + page: query.get("page") ?? "0", + }); + return { redirect: to, queryParams: { page: String(parsed - 1) } }; +}; From 65851b891104c638affe77ddea3626a17270b0d1 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:55:23 -0400 Subject: [PATCH 47/82] reject out-of-range page jumps instead of clamping Co-Authored-By: Claude Fable 5 --- src/routes/page-jump/modal.ts | 1 + src/routes/page-jump/post.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/routes/page-jump/modal.ts b/src/routes/page-jump/modal.ts index ca78265..7cfb79d 100644 --- a/src/routes/page-jump/modal.ts +++ b/src/routes/page-jump/modal.ts @@ -15,6 +15,7 @@ export const pageJumpModal: Handler<"MODAL"> = (router, interaction, state) => { queryParams: { to: query.get("to") ?? "/", page: query.get("page") ?? "0", + pageCount, }, }) .setTitle("Go to page") diff --git a/src/routes/page-jump/post.ts b/src/routes/page-jump/post.ts index a113acd..c34dd29 100644 --- a/src/routes/page-jump/post.ts +++ b/src/routes/page-jump/post.ts @@ -6,10 +6,10 @@ export const pageJumpPost: Handler<"POST"> = (router, interaction, state) => { const to = query.get("to") ?? "/"; const input = state.fields?.getTextInputValue("page") ?? ""; const parsed = parseInt(input, 10); - // a bad entry stays on the page the modal was opened from; an oversized - // number needs no check because paginate clamps to the last page - if (isNaN(parsed) || parsed < 1) - return flashRedirect(to, `"${input}" is not a page number`, "warn", { + const pageCount = parseInt(query.get("pageCount") ?? "1", 10) || 1; + // a bad or out-of-range entry stays on the page the modal was opened from + if (isNaN(parsed) || parsed < 1 || parsed > pageCount) + return flashRedirect(to, `"${input}" is not a valid page number`, "warn", { page: query.get("page") ?? "0", }); return { redirect: to, queryParams: { page: String(parsed - 1) } }; From 06ffc7b76b913bfc6c4c73bab8c472cce3f22d95 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:07:18 -0400 Subject: [PATCH 48/82] show totals in paged panels instead of repeating the select's entries Co-Authored-By: Claude Fable 5 --- src/routes/filter/[scope]/get.ts | 15 ++++++++------- src/routes/lib/paging.ts | 10 ++++++++++ src/routes/recipients/[scope]/get.ts | 11 ++++++----- src/routes/ring/get.ts | 3 +-- src/routes/signups/get.ts | 13 +++++++------ src/routes/signups/roles/_shared.ts | 12 ++++-------- src/routes/signups/roles/by-channel/get.ts | 4 +--- src/routes/signups/roles/by-role/get.ts | 4 +--- 8 files changed, 38 insertions(+), 34 deletions(-) diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index e1b8b18..4dde958 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -1,6 +1,10 @@ import { navBar, paginationRows, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; -import { paginate, SELECT_MAX_VALUES } from "@routes/lib/paging"; +import { + pagedCountLine, + paginate, + SELECT_MAX_VALUES, +} from "@routes/lib/paging"; import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; import { @@ -16,7 +20,6 @@ import { } from "discord.js"; import { filterType, getFilter } from "@db/filters"; -import { mentionUser } from "@main/ring"; import { panelPath } from "../_shared"; @@ -33,15 +36,13 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { state.queryParams.get("page"), ); - const memberList = - pageItems.length > 0 ? pageItems.map(mentionUser).join(" ") : "None"; const description = withFlash( state.queryParams, `${scopeName(scope, "filter", { capitalize: true })} is a **${type}**.\n` + (type === "whitelist" - ? "Only the people listed here can ring you, and you only ring them." - : "The people listed here can't ring you, and you won't ring them.") + - `\n\n**Members${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${memberList}`, + ? "Only the people listed below can ring you, and you only ring them." + : "The people listed below can't ring you, and you won't ring them.") + + `\n\n${pagedCountLine("Members", entries.length, pageCount)}`, ); const scopeSelect = new ActionRowBuilder() diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts index 01b770b..682f89f 100644 --- a/src/routes/lib/paging.ts +++ b/src/routes/lib/paging.ts @@ -10,6 +10,16 @@ export type Page = { pageCount: number; }; +// the "**Label** · 23 across 3 pages" line paged panels show instead of +// repeating the entries the select below already displays as its selection; +// the pager button carries which page is being viewed +export const pagedCountLine = ( + label: string, + total: number, + pageCount: number, +): string => + `**${label}** · ${total > 0 ? total : "None"}${pageCount > 1 ? ` across ${pageCount} pages` : ""}`; + // slices one select-menu page out of a list. The last page is always // partial (a length that is an exact multiple of PAGE_SIZE gets a trailing // empty page), so adding stays possible even when every existing page is diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index ebc67d1..bae534b 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -1,7 +1,11 @@ import { navBar, row, subNav } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; -import { paginate, SELECT_MAX_VALUES } from "@routes/lib/paging"; +import { + pagedCountLine, + paginate, + SELECT_MAX_VALUES, +} from "@routes/lib/paging"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; import { @@ -18,7 +22,6 @@ import { import { getAutoRingSetting } from "@db/auto-ring"; import { getDefaultRingees } from "@db/default-ringees"; -import { mentionUser } from "@main/ring"; import { panelPath } from "../_shared"; @@ -67,13 +70,11 @@ export const recipientsGet: Handler<"GET"> = async ( ? `${autoRing.override ? "On" : "Off"} for <#${channelId}> (global: ${autoRing.global ? "on" : "off"})` : `${autoRing.effective ? "On" : "Off"} (from your global setting)`; - const memberList = - pageItems.length > 0 ? pageItems.map(mentionUser).join(" ") : "None"; const description = withFlash( state.queryParams, `These people get rung when you use ${commandMention(state.globals, "ring_defaults")}${channelId ? ` in <#${channelId}>` : ""}, or on every voice channel join if auto-ring is on.\n\n` + `**Auto-ring** · ${autoRingValue}\n` + - `**Ringees${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${memberList}`, + pagedCountLine("Ringees", sorted.length, pageCount), ); const scopeSelect = new ActionRowBuilder() diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index 278f54d..d4e0103 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -9,7 +9,6 @@ import { import { ActionRowBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; import { getAllDefaultRingees } from "@db/default-ringees"; -import { joinWithAnd, mentionUser } from "@main/ring"; import { NOT_IN_VC, PANEL, voiceChannelOf } from "./_shared"; @@ -41,7 +40,7 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { const description = withFlash( state.queryParams, `Ringing people in <#${channel.id}>.\n\n` + - `**Your defaults** · ${defaults.length > 0 ? joinWithAnd(defaults.map(mentionUser)) : "None"}`, + `**Your defaults** · ${defaults.length > 0 ? defaults.length : "None"}`, ); return { diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index d48949d..b4dd3aa 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -6,7 +6,11 @@ import { subNav, } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; -import { paginate, SELECT_MAX_VALUES } from "@routes/lib/paging"; +import { + pagedCountLine, + paginate, + SELECT_MAX_VALUES, +} from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { RouteChannelSelectMenuBuilder } from "discord-embed-router"; import { ActionRowBuilder, ChannelType, EmbedBuilder } from "discord.js"; @@ -16,7 +20,6 @@ import { COLOR, guildOnlyRender, guildSignups, - mentionChannel, PANEL, ROLES, } from "./_shared"; @@ -36,12 +39,10 @@ export const signupsGet: Handler<"GET"> = async ( state.queryParams.get("page"), ); - const channelList = - pageItems.length > 0 ? pageItems.map(mentionChannel).join(" ") : "None"; const description = withFlash( state.queryParams, - "You get rung when someone starts a call in one of these channels.\n\n" + - `**Signups${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${channelList}`, + "You get rung when someone starts a call in one of your signed-up channels. Edit them below.\n\n" + + pagedCountLine("Signups", signups.length, pageCount), ); return { diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index bbb7322..89a43ec 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -1,6 +1,6 @@ import { navBar, paginationRows, subNav } from "@routes/lib/components"; import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { Page, withPageLabel } from "@routes/lib/paging"; +import { Page, pagedCountLine, withPageLabel } from "@routes/lib/paging"; import { RingRouter } from "@routes/types"; import { RouteRedirect } from "discord-embed-router"; import { @@ -170,8 +170,7 @@ export const renderRoleScope = ({ scope, scopeMention, linkedLabel, - linkedItems, - itemMention, + linkedCount, scopeSelectRow, editSelectRow, basePath, @@ -184,17 +183,14 @@ export const renderRoleScope = ({ scope: string; scopeMention: (id: string) => string; linkedLabel: string; - linkedItems: string[]; - itemMention: (id: string) => string; + linkedCount: number; scopeSelectRow: APIActionRowComponent; editSelectRow: APIActionRowComponent; basePath: string; } & Pick) => { - const linkedList = - linkedItems.length > 0 ? linkedItems.map(itemMention).join(" ") : "None"; const body = `${LEAD}\n\n**Viewing** ${scopeMention(scope)}\n` + - `**${linkedLabel}${pageCount > 1 ? ` (page ${page + 1} of ${pageCount})` : ""}** · ${linkedList}`; + pagedCountLine(linkedLabel, linkedCount, pageCount); return roleFrame({ router, interaction, diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index 9a0a532..08cabca 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -8,7 +8,6 @@ import { import { ActionRowBuilder, ChannelType } from "discord.js"; import { getVoiceChatSignups } from "@db/voice-chats"; -import { mentionRole } from "@main/ring"; import { BY_CHANNEL, @@ -81,8 +80,7 @@ export const rolesByChannelGet: Handler<"GET"> = async ( scope, scopeMention: mentionChannel, linkedLabel: "Roles pinged here", - linkedItems: pageItems, - itemMention: mentionRole, + linkedCount: roleIds.length, scopeSelectRow, editSelectRow, basePath: `${BY_CHANNEL}/${scope}`, diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index 60d09ce..a239831 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -20,7 +20,6 @@ import { canManageRoleSignups, guildOnlyRender, guildVoiceChannelIds, - mentionChannel, noPermissionRender, } from "../../_shared"; import { rolesGet } from "../get"; @@ -86,8 +85,7 @@ export const rolesByRoleGet: Handler<"GET"> = async ( scope, scopeMention: mentionRole, linkedLabel: "Signed up for", - linkedItems: pageItems, - itemMention: mentionChannel, + linkedCount: channelIds.length, scopeSelectRow, editSelectRow, basePath: `${BY_ROLE}/${scope}`, From 80fe1f409dc1b4f358f3853e4806fb7e29c4e565 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:16:51 -0400 Subject: [PATCH 49/82] inline the role-signups view bodies into their GET handlers The shared _shared.ts keeps roleFrame (the chrome every view ends in) while each GET builds its own body and rows, so a handler shows what it renders without a second file. Co-Authored-By: Claude Fable 5 --- src/routes/signups/roles/_shared.ts | 78 ++-------------------- src/routes/signups/roles/by-channel/get.ts | 32 +++++---- src/routes/signups/roles/by-role/get.ts | 32 +++++---- src/routes/signups/roles/get.ts | 10 +-- 4 files changed, 48 insertions(+), 104 deletions(-) diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index 89a43ec..01bce49 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -1,6 +1,6 @@ -import { navBar, paginationRows, subNav } from "@routes/lib/components"; +import { navBar, subNav } from "@routes/lib/components"; import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { Page, pagedCountLine, withPageLabel } from "@routes/lib/paging"; +import { withPageLabel } from "@routes/lib/paging"; import { RingRouter } from "@routes/types"; import { RouteRedirect } from "discord-embed-router"; import { @@ -19,7 +19,7 @@ export const BY_CHANNEL = "/signups/roles/by-channel"; export const BY_ROLE = "/signups/roles/by-role"; // the shared lead sentence; both orientations describe the same feature -const LEAD = +export const LEAD = "When someone joins a voice channel, its signed-up roles get pinged."; // the scope carried in the path, or null when nothing is picked yet (unlike @@ -107,7 +107,7 @@ export const commitRoleEdit = async ({ // the frame every role-signups view ends in. Rows read low-to-high level top to // bottom: the caller's content rows, then the My signups / Role signups switch, // then the section bar -const roleFrame = ({ +export const roleFrame = ({ router, interaction, queryParams, @@ -135,73 +135,3 @@ const roleFrame = ({ navBar(router, interaction, { active: "signups" }), ], }); - -// the entry view with nothing picked: a channel select and a role select. The -// first one the user touches sets the orientation; clearing that scope later -// returns here -export const renderRoleNeutral = ({ - router, - interaction, - queryParams, - channelSelectRow, - roleSelectRow, -}: { - router: RingRouter; - interaction: Interaction; - queryParams: URLSearchParams; - channelSelectRow: APIActionRowComponent; - roleSelectRow: APIActionRowComponent; -}) => - roleFrame({ - router, - interaction, - queryParams, - body: `${LEAD}\n\nPick a voice channel or a role above to view and edit its signups.`, - rows: [channelSelectRow, roleSelectRow], - }); - -// an oriented view: one scope is picked (a channel or a role) and its linked -// items are editable. The caller builds the scope and edit selects, since their -// menu types differ; the frame, copy, and rows are shared -export const renderRoleScope = ({ - router, - interaction, - queryParams, - scope, - scopeMention, - linkedLabel, - linkedCount, - scopeSelectRow, - editSelectRow, - basePath, - page, - pageCount, -}: { - router: RingRouter; - interaction: Interaction; - queryParams: URLSearchParams; - scope: string; - scopeMention: (id: string) => string; - linkedLabel: string; - linkedCount: number; - scopeSelectRow: APIActionRowComponent; - editSelectRow: APIActionRowComponent; - basePath: string; -} & Pick) => { - const body = - `${LEAD}\n\n**Viewing** ${scopeMention(scope)}\n` + - pagedCountLine(linkedLabel, linkedCount, pageCount); - return roleFrame({ - router, - interaction, - queryParams, - body, - // the scope select leads as the page's context, then its edit list and - // pager; the switch and section bar follow in the frame - rows: [ - scopeSelectRow, - editSelectRow, - ...paginationRows(router, basePath, { page, pageCount }), - ], - }); -}; diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index 08cabca..c72196c 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -1,5 +1,9 @@ -import { homeButton, row } from "@routes/lib/components"; -import { paginate, SELECT_MAX_VALUES } from "@routes/lib/paging"; +import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { + pagedCountLine, + paginate, + SELECT_MAX_VALUES, +} from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { RouteChannelSelectMenuBuilder, @@ -11,7 +15,8 @@ import { getVoiceChatSignups } from "@db/voice-chats"; import { BY_CHANNEL, - renderRoleScope, + LEAD, + roleFrame, roleScopeOf, sortRoleIds, } from "../_shared"; @@ -73,18 +78,19 @@ export const rolesByChannelGet: Handler<"GET"> = async ( ) .toJSON(); - return renderRoleScope({ + return roleFrame({ router, interaction, queryParams: state.queryParams, - scope, - scopeMention: mentionChannel, - linkedLabel: "Roles pinged here", - linkedCount: roleIds.length, - scopeSelectRow, - editSelectRow, - basePath: `${BY_CHANNEL}/${scope}`, - page, - pageCount, + body: + `${LEAD}\n\n**Viewing** ${mentionChannel(scope)}\n` + + pagedCountLine("Roles pinged here", roleIds.length, pageCount), + // the scope select leads as the page's context, then its edit list and + // pager; the switch and section bar follow in the frame + rows: [ + scopeSelectRow, + editSelectRow, + ...paginationRows(router, `${BY_CHANNEL}/${scope}`, { page, pageCount }), + ], }); }; diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index a239831..1f04e75 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -1,5 +1,9 @@ -import { homeButton, row } from "@routes/lib/components"; -import { paginate, SELECT_MAX_VALUES } from "@routes/lib/paging"; +import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { + pagedCountLine, + paginate, + SELECT_MAX_VALUES, +} from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { RouteChannelSelectMenuBuilder, @@ -12,7 +16,8 @@ import { mentionRole } from "@main/ring"; import { BY_ROLE, - renderRoleScope, + LEAD, + roleFrame, roleScopeOf, sortChannelIds, } from "../_shared"; @@ -78,18 +83,19 @@ export const rolesByRoleGet: Handler<"GET"> = async ( ) .toJSON(); - return renderRoleScope({ + return roleFrame({ router, interaction, queryParams: state.queryParams, - scope, - scopeMention: mentionRole, - linkedLabel: "Signed up for", - linkedCount: channelIds.length, - scopeSelectRow, - editSelectRow, - basePath: `${BY_ROLE}/${scope}`, - page, - pageCount, + body: + `${LEAD}\n\n**Viewing** ${mentionRole(scope)}\n` + + pagedCountLine("Signed up for", channelIds.length, pageCount), + // the scope select leads as the page's context, then its edit list and + // pager; the switch and section bar follow in the frame + rows: [ + scopeSelectRow, + editSelectRow, + ...paginationRows(router, `${BY_ROLE}/${scope}`, { page, pageCount }), + ], }); }; diff --git a/src/routes/signups/roles/get.ts b/src/routes/signups/roles/get.ts index 223b006..8b5c8eb 100644 --- a/src/routes/signups/roles/get.ts +++ b/src/routes/signups/roles/get.ts @@ -11,7 +11,7 @@ import { guildOnlyRender, noPermissionRender, } from "../_shared"; -import { BY_CHANNEL, BY_ROLE, renderRoleNeutral } from "./_shared"; +import { BY_CHANNEL, BY_ROLE, LEAD, roleFrame } from "./_shared"; export const rolesGet: Handler<"GET"> = async (router, interaction, state) => { const guild = interaction.guild; @@ -40,11 +40,13 @@ export const rolesGet: Handler<"GET"> = async (router, interaction, state) => { ) .toJSON(); - return renderRoleNeutral({ + // the entry view with nothing picked: the first select the user touches + // sets the orientation; clearing that scope later returns here + return roleFrame({ router, interaction, queryParams: state.queryParams, - channelSelectRow, - roleSelectRow, + body: `${LEAD}\n\nPick a voice channel or a role above to view and edit its signups.`, + rows: [channelSelectRow, roleSelectRow], }); }; From 57d063c611486dbe1f2f3b69d6642fef7a4190a3 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:23:20 -0400 Subject: [PATCH 50/82] fold the home-button row into guildOnlyRender and fix the PAGE_SIZE comment Co-Authored-By: Claude Fable 5 --- src/routes/lib/paging.ts | 3 ++- src/routes/signups/_shared.ts | 6 +++--- src/routes/signups/get.ts | 11 ++--------- src/routes/signups/roles/by-channel/get.ts | 5 ++--- src/routes/signups/roles/by-role/get.ts | 5 ++--- src/routes/signups/roles/get.ts | 4 +--- 6 files changed, 12 insertions(+), 22 deletions(-) diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts index 682f89f..4a5e1ed 100644 --- a/src/routes/lib/paging.ts +++ b/src/routes/lib/paging.ts @@ -1,4 +1,5 @@ -// Discord's cap on select menu options and default values +// how many entries a paged panel shows at once; a deliberate readability +// choice well under SELECT_MAX_VALUES export const PAGE_SIZE = 10; // Discord's cap on select menu max_values; edit selects allow it in full so // users can add more entries per submit than one page displays. diff --git a/src/routes/signups/_shared.ts b/src/routes/signups/_shared.ts index 3d3ab9f..72466ed 100644 --- a/src/routes/signups/_shared.ts +++ b/src/routes/signups/_shared.ts @@ -32,15 +32,15 @@ export const guildVoiceChannelIds = (guild: Guild): string[] => // panels are ephemeral and per-guild; anything reached outside a guild // (e.g. a DM) has nothing to show -export const guildOnlyRender = { +export const guildOnlyRender = (router: Parameters>[0]) => ({ embeds: [ new EmbedBuilder() .setColor(COLOR) .setTitle("Signups") .setDescription("⚠️ Signups only work inside a Discord server"), ], - components: [], -}; + components: [row(homeButton(router))], +}); // the user's signups, restricted to this guild's voice channels and sorted // by channel name for stable paging diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index b4dd3aa..feb0fe3 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -1,10 +1,4 @@ -import { - homeButton, - navBar, - paginationRows, - row, - subNav, -} from "@routes/lib/components"; +import { navBar, paginationRows, subNav } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { pagedCountLine, @@ -30,8 +24,7 @@ export const signupsGet: Handler<"GET"> = async ( state, ) => { const guild = interaction.guild; - if (!guild) - return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!guild) return guildOnlyRender(router); const signups = await guildSignups(interaction.user.id, guild); const { pageItems, page, pageCount } = paginate( diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index c72196c..9ebf5d2 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -1,4 +1,4 @@ -import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { paginationRows } from "@routes/lib/components"; import { pagedCountLine, paginate, @@ -34,8 +34,7 @@ export const rolesByChannelGet: Handler<"GET"> = async ( state, ) => { const guild = interaction.guild; - if (!guild) - return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!guild) return guildOnlyRender(router); if (!canManageRoleSignups(interaction)) return noPermissionRender(router); const scope = roleScopeOf(state.params); diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index 1f04e75..961bce8 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -1,4 +1,4 @@ -import { homeButton, paginationRows, row } from "@routes/lib/components"; +import { paginationRows } from "@routes/lib/components"; import { pagedCountLine, paginate, @@ -35,8 +35,7 @@ export const rolesByRoleGet: Handler<"GET"> = async ( state, ) => { const guild = interaction.guild; - if (!guild) - return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!guild) return guildOnlyRender(router); if (!canManageRoleSignups(interaction)) return noPermissionRender(router); const scope = roleScopeOf(state.params); diff --git a/src/routes/signups/roles/get.ts b/src/routes/signups/roles/get.ts index 8b5c8eb..2bf68ac 100644 --- a/src/routes/signups/roles/get.ts +++ b/src/routes/signups/roles/get.ts @@ -1,4 +1,3 @@ -import { homeButton, row } from "@routes/lib/components"; import { Handler } from "@routes/types"; import { RouteChannelSelectMenuBuilder, @@ -15,8 +14,7 @@ import { BY_CHANNEL, BY_ROLE, LEAD, roleFrame } from "./_shared"; export const rolesGet: Handler<"GET"> = async (router, interaction, state) => { const guild = interaction.guild; - if (!guild) - return { ...guildOnlyRender, components: [row(homeButton(router))] }; + if (!guild) return guildOnlyRender(router); if (!canManageRoleSignups(interaction)) return noPermissionRender(router); const channelSelectRow = new ActionRowBuilder() From 7b83b6328328689ccc92d638ea0c857aa2dd8284 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:40:20 -0400 Subject: [PATCH 51/82] cover the page-jump validation with tests Co-Authored-By: Claude Fable 5 --- src/routes/page-jump/page-jump.test.ts | 69 ++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/routes/page-jump/page-jump.test.ts diff --git a/src/routes/page-jump/page-jump.test.ts b/src/routes/page-jump/page-jump.test.ts new file mode 100644 index 0000000..7eec1b7 --- /dev/null +++ b/src/routes/page-jump/page-jump.test.ts @@ -0,0 +1,69 @@ +import { Interaction } from "discord.js"; +import { expect, test } from "vitest"; + +import { pageJumpPost } from "./post"; + +const interaction = { user: { id: "caller" } } as unknown as Interaction; + +const jump = async (query: string, input: string) => + pageJumpPost(undefined as never, interaction, { + params: {}, + path: "/page-jump", + queryParams: new URLSearchParams(query), + timestamp: 0, + fields: { getTextInputValue: () => input }, + } as unknown as Parameters[2]); + +const flashParams = (result: Awaited>) => + new URLSearchParams(result.queryParams as Record); + +test("a page number in range redirects to that page zero-indexed", async () => { + const result = await jump("to=/signups&page=0&pageCount=3", "2"); + + expect(result.redirect).toBe("/signups"); + expect(result.queryParams).toEqual({ page: "1" }); +}); + +test("both range bounds are accepted", async () => { + expect( + (await jump("to=/signups&page=1&pageCount=3", "1")).queryParams, + ).toEqual({ page: "0" }); + expect( + (await jump("to=/signups&page=1&pageCount=3", "3")).queryParams, + ).toEqual({ page: "2" }); +}); + +test("a page number past the last page is rejected, staying on the origin page", async () => { + const result = await jump("to=/signups&page=1&pageCount=3", "4"); + + expect(result.redirect).toBe("/signups"); + const params = flashParams(result); + expect(params.get("level")).toBe("warn"); + expect(params.get("flash")).toContain('"4" is not a valid page number'); + expect(params.get("page")).toBe("1"); +}); + +test("zero is rejected (pages are one-based)", async () => { + const params = flashParams(await jump("to=/signups&page=0&pageCount=3", "0")); + + expect(params.get("level")).toBe("warn"); + expect(params.get("flash")).toContain('"0" is not a valid page number'); +}); + +test("non-numeric input is rejected", async () => { + const params = flashParams( + await jump("to=/signups&page=0&pageCount=3", "abc"), + ); + + expect(params.get("level")).toBe("warn"); + expect(params.get("flash")).toContain('"abc" is not a valid page number'); +}); + +test("a missing pageCount param only accepts page 1", async () => { + expect((await jump("to=/signups&page=0", "1")).queryParams).toEqual({ + page: "0", + }); + expect(flashParams(await jump("to=/signups&page=0", "2")).get("level")).toBe( + "warn", + ); +}); From 9a047bbd7fabfbc9e2124beab61b7e88344904d2 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:55:32 -0400 Subject: [PATCH 52/82] reset edit selects after every submission A ts nonce in each paged edit select's POST pattern gives every render a fresh customId; the Discord client keeps a select's in-flight selection when a message edit leaves the component unchanged, so submissions kept their picks visible instead of snapping back to the page's defaults. Co-Authored-By: Claude Fable 5 --- src/routes/filter/[scope]/get.ts | 3 ++- src/routes/lib/paging.ts | 13 +++++++++++++ src/routes/recipients/[scope]/get.ts | 3 ++- src/routes/signups/get.ts | 3 ++- src/routes/signups/roles/by-channel/get.ts | 3 ++- src/routes/signups/roles/by-role/get.ts | 3 ++- 6 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index 4dde958..88adb02 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -2,6 +2,7 @@ import { navBar, paginationRows, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { pagedCountLine, + pagedEditParams, paginate, SELECT_MAX_VALUES, } from "@routes/lib/paging"; @@ -70,7 +71,7 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { .setDefaultUsers(...pageItems) .setPattern(`${panelPath(scope)}/members`, { method: "POST", - queryParams: { page: String(page) }, + queryParams: pagedEditParams(page, state.timestamp), }), ) .toJSON(); diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts index 4a5e1ed..e7c0a40 100644 --- a/src/routes/lib/paging.ts +++ b/src/routes/lib/paging.ts @@ -21,6 +21,19 @@ export const pagedCountLine = ( ): string => `**${label}** · ${total > 0 ? total : "None"}${pageCount > 1 ? ` across ${pageCount} pages` : ""}`; +// query params for a paged edit select's POST pattern. The ts nonce gives +// every render a fresh customId: the Discord client keeps a select's +// in-flight selection when a message edit leaves the component unchanged, +// so without it a submission's picks stay visibly selected instead of +// resetting to the page's default values +export const pagedEditParams = ( + page: number, + timestamp: number, +): Record => ({ + page: String(page), + ts: String(timestamp), +}); + // slices one select-menu page out of a list. The last page is always // partial (a length that is an exact multiple of PAGE_SIZE gets a trailing // empty page), so adding stays possible even when every existing page is diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index bae534b..006f91c 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -3,6 +3,7 @@ import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; import { pagedCountLine, + pagedEditParams, paginate, SELECT_MAX_VALUES, } from "@routes/lib/paging"; @@ -102,7 +103,7 @@ export const recipientsGet: Handler<"GET"> = async ( .setDefaultUsers(...pageItems) .setPattern(`${panelPath(scope)}/members`, { method: "POST", - queryParams: { page: String(page) }, + queryParams: pagedEditParams(page, state.timestamp), }), ) .toJSON(); diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index feb0fe3..215b53d 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -2,6 +2,7 @@ import { navBar, paginationRows, subNav } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { pagedCountLine, + pagedEditParams, paginate, SELECT_MAX_VALUES, } from "@routes/lib/paging"; @@ -56,7 +57,7 @@ export const signupsGet: Handler<"GET"> = async ( .setDefaultChannels(...pageItems) .setPattern(`${PANEL}/members`, { method: "POST", - queryParams: { page: String(page) }, + queryParams: pagedEditParams(page, state.timestamp), }), ) .toJSON(), diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index 9ebf5d2..189c908 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -1,6 +1,7 @@ import { paginationRows } from "@routes/lib/components"; import { pagedCountLine, + pagedEditParams, paginate, SELECT_MAX_VALUES, } from "@routes/lib/paging"; @@ -72,7 +73,7 @@ export const rolesByChannelGet: Handler<"GET"> = async ( .setDefaultRoles(...pageItems) .setPattern(`${BY_CHANNEL}/${scope}/roles`, { method: "POST", - queryParams: { page: String(page) }, + queryParams: pagedEditParams(page, state.timestamp), }), ) .toJSON(); diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index 961bce8..226045d 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -1,6 +1,7 @@ import { paginationRows } from "@routes/lib/components"; import { pagedCountLine, + pagedEditParams, paginate, SELECT_MAX_VALUES, } from "@routes/lib/paging"; @@ -77,7 +78,7 @@ export const rolesByRoleGet: Handler<"GET"> = async ( .setDefaultChannels(...pageItems) .setPattern(`${BY_ROLE}/${scope}/channels`, { method: "POST", - queryParams: { page: String(page) }, + queryParams: pagedEditParams(page, state.timestamp), }), ) .toJSON(); From 6d8d0922bfe7a3b68c7930418e450faab0d6c3c1 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:03:31 -0400 Subject: [PATCH 53/82] label flashed entries with their page when it is not the one shown Every flash that names an entry still in the list (adds, cross-page reselections, already-present adapter notices) labels it with the page the post-edit sorted list places it on, and drops the label when that is the page being viewed. Co-Authored-By: Claude Fable 5 --- src/routes/filter/[scope]/members/post.ts | 36 +++++++++++++------ src/routes/lib/paging.test.ts | 10 +++++- src/routes/lib/paging.ts | 13 ++++--- src/routes/recipients/[scope]/members/post.ts | 22 +++++++++--- src/routes/signups/members/post.ts | 23 +++++++----- src/routes/signups/roles/_shared.ts | 20 ++++++++--- .../signups/roles/by-channel/roles/post.ts | 1 + .../signups/roles/by-role/channels/post.ts | 1 + 8 files changed, 92 insertions(+), 34 deletions(-) diff --git a/src/routes/filter/[scope]/members/post.ts b/src/routes/filter/[scope]/members/post.ts index 08c1c3f..cb359d1 100644 --- a/src/routes/filter/[scope]/members/post.ts +++ b/src/routes/filter/[scope]/members/post.ts @@ -1,5 +1,9 @@ import { flashRedirect } from "@routes/lib/flash"; -import { resolveSelectionEdit, withPageLabel } from "@routes/lib/paging"; +import { + paginate, + resolveSelectionEdit, + withPageLabel, +} from "@routes/lib/paging"; import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; @@ -62,14 +66,28 @@ export const filterMembersPost: Handler<"POST"> = async ( ]); const changed = toAdd.length > 0 || toRemove.length > 0; - const target = mentionUser(addsRequested[0] ?? removesRequested[0] ?? ""); + // the post-edit list places every entry the flash mentions, so entries + // that land or live off the rendered page get a page label + const removeSet = new Set(toRemove); + const after = [ + ...entries.filter((id) => !removeSet.has(id)), + ...toAdd, + ].sort(); + const { page } = paginate(after, query.get("page")); + const label = withPageLabel(after, mentionUser, page); + + const targetId = addsRequested[0] ?? removesRequested[0] ?? ""; + // mention with a page label when the target is in the list, plain when + // the flash says it isn't + const target = mentionUser(targetId); + const listedTarget = label(targetId); let flash: string; if (intent === "block") { flash = addsRequested.length > 0 ? toAdd.length > 0 - ? `Blocked ${target}` - : `${target} is already blocked` + ? `Blocked ${listedTarget}` + : `${listedTarget} is already blocked` : toRemove.length > 0 ? `Unblocked ${target}` : `${target} isn't blocked`; @@ -77,22 +95,20 @@ export const filterMembersPost: Handler<"POST"> = async ( flash = addsRequested.length > 0 ? toAdd.length > 0 - ? `Whitelisted ${target}` - : `${target} is already whitelisted` + ? `Whitelisted ${listedTarget}` + : `${listedTarget} is already whitelisted` : toRemove.length > 0 ? `Removed ${target} from your whitelist` : `${target} isn't on your whitelist`; } else { const parts = [ - ...(toAdd.length > 0 - ? [`Added ${joinWithAnd(toAdd.map(mentionUser))}`] - : []), + ...(toAdd.length > 0 ? [`Added ${joinWithAnd(toAdd.map(label))}`] : []), ...(toRemove.length > 0 ? [`Removed ${joinWithAnd(toRemove.map(mentionUser))}`] : []), ...(alreadyPresent.length > 0 ? [ - `${joinWithAnd(alreadyPresent.map(withPageLabel(entries, mentionUser)))} ${alreadyPresent.length > 1 ? "are" : "is"} already listed`, + `${joinWithAnd(alreadyPresent.map(label))} ${alreadyPresent.length > 1 ? "are" : "is"} already listed`, ] : []), ]; diff --git a/src/routes/lib/paging.test.ts b/src/routes/lib/paging.test.ts index 18563b6..16f11bd 100644 --- a/src/routes/lib/paging.test.ts +++ b/src/routes/lib/paging.test.ts @@ -106,7 +106,15 @@ test("deselecting everything on a page removes the whole page only", () => { test("withPageLabel points at the page an existing entry lives on", () => { const allItems = ids(PAGE_SIZE * 2); - const label = withPageLabel(allItems, (id) => `#${id}`); + const label = withPageLabel(allItems, (id) => `#${id}`, 2); expect(label("1")).toBe("#1 (page 1)"); expect(label(`${PAGE_SIZE + 1}`)).toBe(`#${PAGE_SIZE + 1} (page 2)`); }); + +test("withPageLabel leaves entries on the viewed page unlabeled", () => { + const allItems = ids(PAGE_SIZE * 2); + expect(withPageLabel(allItems, (id) => `#${id}`, 0)("1")).toBe("#1"); + expect(withPageLabel(allItems, (id) => `#${id}`, 1)(`${PAGE_SIZE + 1}`)).toBe( + `#${PAGE_SIZE + 1}`, + ); +}); diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts index e7c0a40..47d36e6 100644 --- a/src/routes/lib/paging.ts +++ b/src/routes/lib/paging.ts @@ -76,11 +76,16 @@ export const diffSelection = ({ }; // labels a value with the page that displays it, e.g. "#general (page 2)", -// so a flash can point at where an existing entry lives +// so a flash can point at where an entry lives — except on the page in +// view, where the entry is already visible and the label is noise export const withPageLabel = - (allItems: string[], mention: (id: string) => string) => - (value: string): string => - `${mention(value)} (page ${Math.floor(allItems.indexOf(value) / PAGE_SIZE) + 1})`; + (allItems: string[], mention: (id: string) => string, viewedPage: number) => + (value: string): string => { + const page = Math.floor(allItems.indexOf(value) / PAGE_SIZE); + return page === viewedPage + ? mention(value) + : `${mention(value)} (page ${page + 1})`; + }; // resolves an add/remove edit for a paged member select from either input a // handler can receive: a select submission (diffed against the visible page) diff --git a/src/routes/recipients/[scope]/members/post.ts b/src/routes/recipients/[scope]/members/post.ts index 4ae2056..fc3fe9c 100644 --- a/src/routes/recipients/[scope]/members/post.ts +++ b/src/routes/recipients/[scope]/members/post.ts @@ -1,5 +1,9 @@ import { flashRedirect } from "@routes/lib/flash"; -import { resolveSelectionEdit, withPageLabel } from "@routes/lib/paging"; +import { + paginate, + resolveSelectionEdit, + withPageLabel, +} from "@routes/lib/paging"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; @@ -38,16 +42,24 @@ export const recipientsMembersPost: Handler<"POST"> = async ( ...removed.map((id) => removeDefaultRingee(userId, channelId, id)), ]); + // the post-edit list places every entry the flash mentions, so entries + // that land or live off the rendered page get a page label + const removedSet = new Set(removed); + const after = [ + ...ringees.filter((id) => !removedSet.has(id)), + ...added, + ].sort(); + const { page } = paginate(after, state.queryParams.get("page")); + const label = withPageLabel(after, mentionUser, page); + const parts = [ - ...(added.length > 0 - ? [`Added ${joinWithAnd(added.map(mentionUser))}`] - : []), + ...(added.length > 0 ? [`Added ${joinWithAnd(added.map(label))}`] : []), ...(removed.length > 0 ? [`Removed ${joinWithAnd(removed.map(mentionUser))}`] : []), ...(alreadyPresent.length > 0 ? [ - `${joinWithAnd(alreadyPresent.map(withPageLabel(ringees, mentionUser)))} ${alreadyPresent.length > 1 ? "are" : "is"} already added`, + `${joinWithAnd(alreadyPresent.map(label))} ${alreadyPresent.length > 1 ? "are" : "is"} already added`, ] : []), ]; diff --git a/src/routes/signups/members/post.ts b/src/routes/signups/members/post.ts index b6edd2d..d78034d 100644 --- a/src/routes/signups/members/post.ts +++ b/src/routes/signups/members/post.ts @@ -1,5 +1,9 @@ import { flashRedirect } from "@routes/lib/flash"; -import { resolveSelectionEdit, withPageLabel } from "@routes/lib/paging"; +import { + paginate, + resolveSelectionEdit, + withPageLabel, +} from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { addVoiceChatUser, removeVoiceChatUser } from "@db/voice-chats"; @@ -56,18 +60,19 @@ export const signupsMembersPost: Handler<"POST"> = async ( .filter((r) => !r.ok) .map((r) => r.channelId); + // the re-fetched list places every entry the flash mentions, so entries + // that land or live off the rendered page get a page label + const after = await guildSignups(userId, guild); + const { page } = paginate(after, query.get("page")); + const label = withPageLabel(after, mentionChannel, page); + const parts = [ ...(added.length > 0 - ? [`Signed up for ${joinWithAnd(added.map(mentionChannel))}`] - : []), - ...(alreadySignedUp.length > 0 - ? [ - `You are already signed up for ${joinWithAnd(alreadySignedUp.map(mentionChannel))}`, - ] + ? [`Signed up for ${joinWithAnd(added.map(label))}`] : []), - ...(alreadyPresent.length > 0 + ...(alreadySignedUp.length > 0 || alreadyPresent.length > 0 ? [ - `You are already signed up for ${joinWithAnd(alreadyPresent.map(withPageLabel(signups, mentionChannel)))}`, + `You are already signed up for ${joinWithAnd([...alreadySignedUp, ...alreadyPresent].map(label))}`, ] : []), ...(removed.length > 0 diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index 01bce49..f44a619 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -1,6 +1,6 @@ import { navBar, subNav } from "@routes/lib/components"; import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { withPageLabel } from "@routes/lib/paging"; +import { paginate, withPageLabel } from "@routes/lib/paging"; import { RingRouter } from "@routes/types"; import { RouteRedirect } from "discord-embed-router"; import { @@ -61,6 +61,7 @@ export const commitRoleEdit = async ({ mutateAdd, mutateRemove, itemMention, + sortItems, }: { redirect: string; page: number; @@ -71,22 +72,31 @@ export const commitRoleEdit = async ({ mutateAdd: (item: string) => Promise; mutateRemove: (item: string) => Promise; itemMention: (id: string) => string; + sortItems: (items: string[]) => string[]; }): Promise => { const currentSet = new Set(current); const toAdd = addsRequested.filter((item) => !currentSet.has(item)); const toRemove = removesRequested.filter((item) => currentSet.has(item)); await Promise.all([...toAdd.map(mutateAdd), ...toRemove.map(mutateRemove)]); + // the post-edit list places every entry the flash mentions, so entries + // that land or live off the rendered page get a page label + const removeSet = new Set(toRemove); + const after = sortItems([ + ...current.filter((item) => !removeSet.has(item)), + ...toAdd, + ]); + const { page: viewedPage } = paginate(after, String(page)); + const label = withPageLabel(after, itemMention, viewedPage); + const parts = [ - ...(toAdd.length > 0 - ? [`Added ${joinWithAnd(toAdd.map(itemMention))}`] - : []), + ...(toAdd.length > 0 ? [`Added ${joinWithAnd(toAdd.map(label))}`] : []), ...(toRemove.length > 0 ? [`Removed ${joinWithAnd(toRemove.map(itemMention))}`] : []), ...(alreadyPresent.length > 0 ? [ - `${joinWithAnd(alreadyPresent.map(withPageLabel(current, itemMention)))} ${alreadyPresent.length > 1 ? "are" : "is"} already signed up`, + `${joinWithAnd(alreadyPresent.map(label))} ${alreadyPresent.length > 1 ? "are" : "is"} already signed up`, ] : []), ]; diff --git a/src/routes/signups/roles/by-channel/roles/post.ts b/src/routes/signups/roles/by-channel/roles/post.ts index 2cc0573..5fe4451 100644 --- a/src/routes/signups/roles/by-channel/roles/post.ts +++ b/src/routes/signups/roles/by-channel/roles/post.ts @@ -77,5 +77,6 @@ export const rolesByChannelEditPost: Handler<"POST"> = async ( mutateAdd: (roleId) => addVoiceChatRole(scope, roleId), mutateRemove: (roleId) => removeVoiceChatRole(scope, roleId), itemMention: mentionRole, + sortItems: (roleIds) => sortRoleIds(guild, roleIds), }); }; diff --git a/src/routes/signups/roles/by-role/channels/post.ts b/src/routes/signups/roles/by-role/channels/post.ts index af58d80..e456c39 100644 --- a/src/routes/signups/roles/by-role/channels/post.ts +++ b/src/routes/signups/roles/by-role/channels/post.ts @@ -86,5 +86,6 @@ export const rolesByRoleEditPost: Handler<"POST"> = async ( mutateAdd: (channelId) => addVoiceChatRole(channelId, scope), mutateRemove: (channelId) => removeVoiceChatRole(channelId, scope), itemMention: mentionChannel, + sortItems: (channelIds) => sortChannelIds(guild, channelIds), }); }; From 12ca373c6676b2baf090ff7c28825a43e9ad3970 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:26:30 -0400 Subject: [PATCH 54/82] swap the pager row for a shared pager/options control row with resets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every paged panel's third row now toggles between page controls and its option buttons via a ⚙ Options / ◀ Back pair (single-page lists show the options directly). Signups and both role-signup orientations gain a Reset behind it, with the same typed-RESET confirmation as filter and recipients; recipients' auto-ring and reset and filter's type switch and reset move behind the toggle, with their state still shown in the embed. Co-Authored-By: Claude Fable 5 --- src/routes/filter/[scope]/get.ts | 18 ++-- src/routes/index.ts | 18 ++++ src/routes/lib/components.ts | 46 ++++++++-- src/routes/recipients/[scope]/get.ts | 44 ++++------ src/routes/signups/get.ts | 31 ++++++- src/routes/signups/reset/modal.ts | 11 +++ src/routes/signups/reset/post.ts | 42 +++++++++ src/routes/signups/roles/by-channel/get.ts | 17 +++- .../signups/roles/by-channel/reset/modal.ts | 15 ++++ .../signups/roles/by-channel/reset/post.ts | 55 ++++++++++++ src/routes/signups/roles/by-role/get.ts | 17 +++- .../signups/roles/by-role/reset/modal.ts | 15 ++++ .../signups/roles/by-role/reset/post.ts | 61 +++++++++++++ src/routes/signups/signups.test.ts | 86 +++++++++++++++++++ 14 files changed, 426 insertions(+), 50 deletions(-) create mode 100644 src/routes/signups/reset/modal.ts create mode 100644 src/routes/signups/reset/post.ts create mode 100644 src/routes/signups/roles/by-channel/reset/modal.ts create mode 100644 src/routes/signups/roles/by-channel/reset/post.ts create mode 100644 src/routes/signups/roles/by-role/reset/modal.ts create mode 100644 src/routes/signups/roles/by-role/reset/post.ts diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index 88adb02..19c2a20 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -1,4 +1,4 @@ -import { navBar, paginationRows, row } from "@routes/lib/components"; +import { navBar, pagedControls, showOptionsOf } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { pagedCountLine, @@ -76,7 +76,7 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { ) .toJSON(); - const filterActions = row( + const filterOptions = [ new RouteButtonBuilder(router) .setLabel( type === "blacklist" ? "Switch to Whitelist" : "Switch to Blacklist", @@ -90,7 +90,7 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { .setLabel("Reset") .setStyle(ButtonStyle.Danger) .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), - ); + ]; return { embeds: [ @@ -102,13 +102,17 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { .setDescription(description), ], // top to bottom within the page: the scope select leads as context, then - // the member list with its pager and the filter-wide actions; the section - // bar is the global nav pinned at the bottom + // the member list and its control row (pager or the filter-wide options); + // the section bar is the global nav pinned at the bottom components: [ scopeSelect, editSelect, - ...paginationRows(router, panelPath(scope), { page, pageCount }), - filterActions, + ...pagedControls(router, panelPath(scope), { + page, + pageCount, + showOptions: showOptionsOf(state.queryParams), + options: filterOptions, + }), navBar(router, interaction, { active: "filters" }), ], }; diff --git a/src/routes/index.ts b/src/routes/index.ts index 7cd9162..fc7c708 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -27,10 +27,16 @@ import { ringUserPost } from "./ring/user/post"; import { ringUsersPost } from "./ring/users/post"; import { signupsGet } from "./signups/get"; import { signupsMembersPost } from "./signups/members/post"; +import { signupsResetModal } from "./signups/reset/modal"; +import { signupsResetPost } from "./signups/reset/post"; import { rolesByChannelGet } from "./signups/roles/by-channel/get"; +import { rolesByChannelResetModal } from "./signups/roles/by-channel/reset/modal"; +import { rolesByChannelResetPost } from "./signups/roles/by-channel/reset/post"; import { rolesByChannelEditPost } from "./signups/roles/by-channel/roles/post"; import { rolesByRoleEditPost } from "./signups/roles/by-role/channels/post"; import { rolesByRoleGet } from "./signups/roles/by-role/get"; +import { rolesByRoleResetModal } from "./signups/roles/by-role/reset/modal"; +import { rolesByRoleResetPost } from "./signups/roles/by-role/reset/post"; import { rolesGet } from "./signups/roles/get"; // handlers live in files mirroring their route: the folder is the path (with @@ -68,6 +74,10 @@ export const registerRoutes = (router: RingRouter) => { router.get("/signups", signupsGet); router.post("/signups/members", signupsMembersPost); + router.route("/signups/reset", { + modal: signupsResetModal, + post: signupsResetPost, + }); // role signups open on a neutral view offering a channel select and a role // select; picking either sets the orientation and routes to its scoped view. @@ -78,8 +88,16 @@ export const registerRoutes = (router: RingRouter) => { ); router.get("/signups/roles/by-channel/:scope", rolesByChannelGet); router.post("/signups/roles/by-channel/:scope/roles", rolesByChannelEditPost); + router.route("/signups/roles/by-channel/:scope/reset", { + modal: rolesByChannelResetModal, + post: rolesByChannelResetPost, + }); router.get("/signups/roles/by-role/:scope", rolesByRoleGet); router.post("/signups/roles/by-role/:scope/channels", rolesByRoleEditPost); + router.route("/signups/roles/by-role/:scope/reset", { + modal: rolesByRoleResetModal, + post: rolesByRoleResetPost, + }); router.get("/ring", ringGet); router.post("/ring/users", ringUsersPost); diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index be39d48..94467a5 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -126,14 +126,36 @@ export const subNav = ( ), ); -// the conditional pagination row: absent for single-page lists, so the -// result is spread into a components array -export const paginationRows = ( +// a paged panel's control row: the page controls with a switch into the +// panel's option buttons (reset and friends), or the options with the way +// back, keyed by the `options` query param. A single-page list has no pager +// to share the row with, so its options show directly; a panel with no +// options and one page contributes no row, so the result is spread into a +// components array +export const pagedControls = ( router: RingRouter, basePath: string, - { page, pageCount }: Pick, + { + page, + pageCount, + showOptions, + options, + }: Pick & { + showOptions: boolean; + options: RingButton[]; + }, ): APIActionRowComponent[] => { - if (pageCount <= 1) return []; + if (pageCount <= 1) return options.length > 0 ? [row(...options)] : []; + if (showOptions && options.length > 0) + return [ + row( + new RouteButtonBuilder(router) + .setLabel("◀ Back") + .setStyle(ButtonStyle.Secondary) + .setTo(basePath, { queryParams: { page: String(page) } }), + ...options, + ), + ]; const pageButton = (label: string, target: number, disabled: boolean) => new RouteButtonBuilder(router) .setLabel(label) @@ -157,6 +179,20 @@ export const paginationRows = ( }, }), pageButton("Next ▶", page + 1, page === pageCount - 1), + ...(options.length > 0 + ? [ + new RouteButtonBuilder(router) + .setLabel("⚙ Options") + .setStyle(ButtonStyle.Secondary) + .setTo(basePath, { + queryParams: { page: String(page), options: "1" }, + }), + ] + : []), ), ]; }; + +// reads the `options` query param pagedControls' switch buttons carry +export const showOptionsOf = (queryParams: URLSearchParams): boolean => + queryParams.get("options") === "1"; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 006f91c..ca3e884 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -1,4 +1,9 @@ -import { navBar, row, subNav } from "@routes/lib/components"; +import { + navBar, + pagedControls, + showOptionsOf, + subNav, +} from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; import { @@ -108,19 +113,7 @@ export const recipientsGet: Handler<"GET"> = async ( ) .toJSON(); - const ringActions = row( - // prev/next page the recipient list inline, so this dense panel - // spends no separate row on pagination - ...(page > 0 - ? [ - new RouteButtonBuilder(router) - .setLabel("◀") - .setStyle(ButtonStyle.Secondary) - .setTo(panelPath(scope), { - queryParams: { page: String(page - 1) }, - }), - ] - : []), + const ringOptions = [ new RouteButtonBuilder(router) .setLabel(autoRing.effective ? "Disable auto-ring" : "Enable auto-ring") .setStyle( @@ -134,17 +127,7 @@ export const recipientsGet: Handler<"GET"> = async ( .setLabel("Reset") .setStyle(ButtonStyle.Danger) .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), - ...(page < pageCount - 1 - ? [ - new RouteButtonBuilder(router) - .setLabel("▶") - .setStyle(ButtonStyle.Secondary) - .setTo(panelPath(scope), { - queryParams: { page: String(page + 1) }, - }), - ] - : []), - ); + ]; return { embeds: [ @@ -156,12 +139,17 @@ export const recipientsGet: Handler<"GET"> = async ( .setDescription(description), ], // top to bottom within the page: the scope select leads as context, then - // the ringee list and the actions (with the inline pager); the sub-nav and - // section bar are navigation pinned at the bottom + // the ringee list and its control row (pager or the panel options); the + // sub-nav and section bar are navigation pinned at the bottom components: [ scopeSelect, editSelect, - ringActions, + ...pagedControls(router, panelPath(scope), { + page, + pageCount, + showOptions: showOptionsOf(state.queryParams), + options: ringOptions, + }), subNav(router, [ { label: "Quick ring", path: "/ring" }, { label: "Default ringees", path: panelPath(scope), active: true }, diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index 215b53d..f773d2c 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -1,4 +1,9 @@ -import { navBar, paginationRows, subNav } from "@routes/lib/components"; +import { + navBar, + pagedControls, + showOptionsOf, + subNav, +} from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { pagedCountLine, @@ -7,8 +12,16 @@ import { SELECT_MAX_VALUES, } from "@routes/lib/paging"; import { Handler } from "@routes/types"; -import { RouteChannelSelectMenuBuilder } from "discord-embed-router"; -import { ActionRowBuilder, ChannelType, EmbedBuilder } from "discord.js"; +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, +} from "discord.js"; import { canManageRoleSignups, @@ -61,7 +74,17 @@ export const signupsGet: Handler<"GET"> = async ( }), ) .toJSON(), - ...paginationRows(router, PANEL, { page, pageCount }), + ...pagedControls(router, PANEL, { + page, + pageCount, + showOptions: showOptionsOf(state.queryParams), + options: [ + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Danger) + .setTo(`${PANEL}/reset`, { method: "MODAL" }), + ], + }), // the switch into role signups is a management action, so it only // appears for members who can manage role signups ...(canManageRoleSignups(interaction) diff --git a/src/routes/signups/reset/modal.ts b/src/routes/signups/reset/modal.ts new file mode 100644 index 0000000..cd520aa --- /dev/null +++ b/src/routes/signups/reset/modal.ts @@ -0,0 +1,11 @@ +import { confirmModal } from "@routes/lib/confirm"; +import { Handler } from "@routes/types"; + +import { PANEL } from "../_shared"; + +export const signupsResetModal: Handler<"MODAL"> = (router) => + confirmModal(router, { + to: `${PANEL}/reset`, + title: "Reset your signups", + word: "RESET", + }); diff --git a/src/routes/signups/reset/post.ts b/src/routes/signups/reset/post.ts new file mode 100644 index 0000000..5d26e15 --- /dev/null +++ b/src/routes/signups/reset/post.ts @@ -0,0 +1,42 @@ +import { confirmed } from "@routes/lib/confirm"; +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { removeVoiceChatUser } from "@db/voice-chats"; + +import { guildSignups, PANEL } from "../_shared"; + +// clears the caller's signups in this guild only; other guilds keep theirs +export const signupsResetPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return flashRedirect( + PANEL, + "Signups only work inside a Discord server", + "warn", + ); + if (!confirmed(state.fields, "RESET")) + return flashRedirect( + PANEL, + "Confirmation text did not match, your signups were not cleared", + "warn", + ); + + const userId = interaction.user.id; + const signups = await guildSignups(userId, guild); + if (signups.length === 0) + return flashRedirect(PANEL, "You have no signups in this server", "warn"); + + await Promise.all( + signups.map((channelId) => removeVoiceChatUser(channelId, userId)), + ); + return flashRedirect( + PANEL, + `Cleared your ${signups.length} signup${signups.length > 1 ? "s" : ""} in this server`, + "success", + ); +}; diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index 189c908..83a15d8 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -1,4 +1,4 @@ -import { paginationRows } from "@routes/lib/components"; +import { pagedControls, showOptionsOf } from "@routes/lib/components"; import { pagedCountLine, pagedEditParams, @@ -7,10 +7,11 @@ import { } from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { + RouteButtonBuilder, RouteChannelSelectMenuBuilder, RouteRoleSelectMenuBuilder, } from "discord-embed-router"; -import { ActionRowBuilder, ChannelType } from "discord.js"; +import { ActionRowBuilder, ButtonStyle, ChannelType } from "discord.js"; import { getVoiceChatSignups } from "@db/voice-chats"; @@ -90,7 +91,17 @@ export const rolesByChannelGet: Handler<"GET"> = async ( rows: [ scopeSelectRow, editSelectRow, - ...paginationRows(router, `${BY_CHANNEL}/${scope}`, { page, pageCount }), + ...pagedControls(router, `${BY_CHANNEL}/${scope}`, { + page, + pageCount, + showOptions: showOptionsOf(state.queryParams), + options: [ + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Danger) + .setTo(`${BY_CHANNEL}/${scope}/reset`, { method: "MODAL" }), + ], + }), ], }); }; diff --git a/src/routes/signups/roles/by-channel/reset/modal.ts b/src/routes/signups/roles/by-channel/reset/modal.ts new file mode 100644 index 0000000..ddd7857 --- /dev/null +++ b/src/routes/signups/roles/by-channel/reset/modal.ts @@ -0,0 +1,15 @@ +import { confirmModal } from "@routes/lib/confirm"; +import { Handler } from "@routes/types"; + +import { BY_CHANNEL, roleScopeOf } from "../../_shared"; + +export const rolesByChannelResetModal: Handler<"MODAL"> = ( + router, + interaction, + state, +) => + confirmModal(router, { + to: `${BY_CHANNEL}/${roleScopeOf(state.params) ?? ""}/reset`, + title: "Reset this channel's roles", + word: "RESET", + }); diff --git a/src/routes/signups/roles/by-channel/reset/post.ts b/src/routes/signups/roles/by-channel/reset/post.ts new file mode 100644 index 0000000..c15b3fd --- /dev/null +++ b/src/routes/signups/roles/by-channel/reset/post.ts @@ -0,0 +1,55 @@ +import { confirmed } from "@routes/lib/confirm"; +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { getVoiceChatSignups, removeVoiceChatRole } from "@db/voice-chats"; + +import { BY_CHANNEL, roleScopeOf } from "../../_shared"; +import { canManageRoleSignups, mentionChannel } from "../../../_shared"; + +// clears every role pinged in one voice channel +export const rolesByChannelResetPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + if (!interaction.guild) + return flashRedirect( + BY_CHANNEL, + "Signups only work inside a Discord server", + "warn", + ); + if (!canManageRoleSignups(interaction)) + return flashRedirect( + BY_CHANNEL, + "You need the Manage Roles permission to manage role signups", + "warn", + ); + const scope = roleScopeOf(state.params); + if (!scope) return flashRedirect(BY_CHANNEL, "Pick a channel first", "warn"); + + const panel = `${BY_CHANNEL}/${scope}`; + if (!confirmed(state.fields, "RESET")) + return flashRedirect( + panel, + "Confirmation text did not match, the roles were not cleared", + "warn", + ); + + const { roleIds } = await getVoiceChatSignups(scope); + if (roleIds.length === 0) + return flashRedirect( + panel, + `${mentionChannel(scope)} has no roles signed up`, + "warn", + ); + + await Promise.all( + roleIds.map((roleId) => removeVoiceChatRole(scope, roleId)), + ); + return flashRedirect( + panel, + `Cleared the ${roleIds.length} role${roleIds.length > 1 ? "s" : ""} pinged in ${mentionChannel(scope)}`, + "success", + ); +}; diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index 226045d..8e5543c 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -1,4 +1,4 @@ -import { paginationRows } from "@routes/lib/components"; +import { pagedControls, showOptionsOf } from "@routes/lib/components"; import { pagedCountLine, pagedEditParams, @@ -7,10 +7,11 @@ import { } from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { + RouteButtonBuilder, RouteChannelSelectMenuBuilder, RouteRoleSelectMenuBuilder, } from "discord-embed-router"; -import { ActionRowBuilder, ChannelType } from "discord.js"; +import { ActionRowBuilder, ButtonStyle, ChannelType } from "discord.js"; import { getVoiceChatRoleSignups } from "@db/voice-chats"; import { mentionRole } from "@main/ring"; @@ -95,7 +96,17 @@ export const rolesByRoleGet: Handler<"GET"> = async ( rows: [ scopeSelectRow, editSelectRow, - ...paginationRows(router, `${BY_ROLE}/${scope}`, { page, pageCount }), + ...pagedControls(router, `${BY_ROLE}/${scope}`, { + page, + pageCount, + showOptions: showOptionsOf(state.queryParams), + options: [ + new RouteButtonBuilder(router) + .setLabel("Reset") + .setStyle(ButtonStyle.Danger) + .setTo(`${BY_ROLE}/${scope}/reset`, { method: "MODAL" }), + ], + }), ], }); }; diff --git a/src/routes/signups/roles/by-role/reset/modal.ts b/src/routes/signups/roles/by-role/reset/modal.ts new file mode 100644 index 0000000..e3efef8 --- /dev/null +++ b/src/routes/signups/roles/by-role/reset/modal.ts @@ -0,0 +1,15 @@ +import { confirmModal } from "@routes/lib/confirm"; +import { Handler } from "@routes/types"; + +import { BY_ROLE, roleScopeOf } from "../../_shared"; + +export const rolesByRoleResetModal: Handler<"MODAL"> = ( + router, + interaction, + state, +) => + confirmModal(router, { + to: `${BY_ROLE}/${roleScopeOf(state.params) ?? ""}/reset`, + title: "Reset this role's signups", + word: "RESET", + }); diff --git a/src/routes/signups/roles/by-role/reset/post.ts b/src/routes/signups/roles/by-role/reset/post.ts new file mode 100644 index 0000000..6652825 --- /dev/null +++ b/src/routes/signups/roles/by-role/reset/post.ts @@ -0,0 +1,61 @@ +import { confirmed } from "@routes/lib/confirm"; +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { getVoiceChatRoleSignups, removeVoiceChatRole } from "@db/voice-chats"; +import { mentionRole } from "@main/ring"; + +import { BY_ROLE, roleScopeOf } from "../../_shared"; +import { canManageRoleSignups, guildVoiceChannelIds } from "../../../_shared"; + +// clears every voice channel one role is signed up for +export const rolesByRoleResetPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const guild = interaction.guild; + if (!guild) + return flashRedirect( + BY_ROLE, + "Signups only work inside a Discord server", + "warn", + ); + if (!canManageRoleSignups(interaction)) + return flashRedirect( + BY_ROLE, + "You need the Manage Roles permission to manage role signups", + "warn", + ); + const scope = roleScopeOf(state.params); + if (!scope) return flashRedirect(BY_ROLE, "Pick a role first", "warn"); + + const panel = `${BY_ROLE}/${scope}`; + if (!confirmed(state.fields, "RESET")) + return flashRedirect( + panel, + "Confirmation text did not match, the signups were not cleared", + "warn", + ); + + const channelIds = ( + await getVoiceChatRoleSignups(guildVoiceChannelIds(guild)) + ) + .filter((mapping) => mapping.roleId === scope) + .map((mapping) => mapping.channelId); + if (channelIds.length === 0) + return flashRedirect( + panel, + `${mentionRole(scope)} is not signed up for any voice channels`, + "warn", + ); + + await Promise.all( + channelIds.map((channelId) => removeVoiceChatRole(channelId, scope)), + ); + return flashRedirect( + panel, + `Cleared the ${channelIds.length} voice channel${channelIds.length > 1 ? "s" : ""} ${mentionRole(scope)} was signed up for`, + "success", + ); +}; diff --git a/src/routes/signups/signups.test.ts b/src/routes/signups/signups.test.ts index f92cef4..619da30 100644 --- a/src/routes/signups/signups.test.ts +++ b/src/routes/signups/signups.test.ts @@ -3,13 +3,18 @@ import { beforeEach, expect, test, vi } from "vitest"; import { addVoiceChatRole, + getUserVoiceChatSignups, getVoiceChatRoleSignups, getVoiceChatSignups, removeVoiceChatRole, + removeVoiceChatUser, } from "@db/voice-chats"; +import { signupsResetPost } from "./reset/post"; +import { rolesByChannelResetPost } from "./roles/by-channel/reset/post"; import { rolesByChannelEditPost } from "./roles/by-channel/roles/post"; import { rolesByRoleEditPost } from "./roles/by-role/channels/post"; +import { rolesByRoleResetPost } from "./roles/by-role/reset/post"; vi.mock("@db/voice-chats", () => ({ getUserVoiceChatSignups: vi.fn(), @@ -136,3 +141,84 @@ test("unsignuprole removeAll clears every channel the role is signed up to", asy expect(removeVoiceChatRole).not.toHaveBeenCalledWith("vcC", "role1"); expect(result.redirect).toBe("/signups/roles/by-role/role1"); }); + +const resetState = (confirmation: string, scope?: string) => + ({ + params: scope === undefined ? {} : { scope }, + queryParams: new URLSearchParams(), + timestamp: 0, + fields: { getTextInputValue: () => confirmation }, + }) as unknown as Parameters[2]; + +test("a signups reset clears only this guild's signups", async () => { + vi.mocked(getUserVoiceChatSignups).mockResolvedValue(["vc1", "elsewhere"]); + vi.mocked(removeVoiceChatUser).mockResolvedValue(true); + + const result = await signupsResetPost( + undefined as never, + makeInteraction(false), + resetState("RESET"), + ); + + expect(removeVoiceChatUser).toHaveBeenCalledExactlyOnceWith("vc1", "caller"); + expect(flashOf(result).get("level")).toBe("success"); +}); + +test("a signups reset without matching confirmation mutates nothing", async () => { + vi.mocked(getUserVoiceChatSignups).mockResolvedValue(["vc1"]); + + const result = await signupsResetPost( + undefined as never, + makeInteraction(false), + resetState("nope"), + ); + + expect(removeVoiceChatUser).not.toHaveBeenCalled(); + expect(flashOf(result).get("level")).toBe("warn"); + expect(flashOf(result).get("flash")).toContain("did not match"); +}); + +test("a channel-roles reset clears every role pinged in the channel", async () => { + vi.mocked(getVoiceChatSignups).mockResolvedValue({ + userIds: [], + roleIds: ["r1", "r2"], + }); + + const result = await rolesByChannelResetPost( + undefined as never, + makeInteraction(true), + resetState("RESET", "vc1"), + ); + + expect(removeVoiceChatRole).toHaveBeenCalledWith("vc1", "r1"); + expect(removeVoiceChatRole).toHaveBeenCalledWith("vc1", "r2"); + expect(result.redirect).toBe("/signups/roles/by-channel/vc1"); +}); + +test("a role-signups reset without Manage Roles mutates nothing", async () => { + const result = await rolesByRoleResetPost( + undefined as never, + makeInteraction(false), + resetState("RESET", "role1"), + ); + + expect(removeVoiceChatRole).not.toHaveBeenCalled(); + expect(flashOf(result).get("flash")).toContain("Manage Roles"); +}); + +test("a role-channels reset clears every channel the role is signed up for", async () => { + vi.mocked(getVoiceChatRoleSignups).mockResolvedValue([ + { roleId: "role1", channelId: "vcA" }, + { roleId: "other", channelId: "vcC" }, + ]); + + const result = await rolesByRoleResetPost( + undefined as never, + makeInteraction(true), + resetState("RESET", "role1"), + ); + + expect(removeVoiceChatRole).toHaveBeenCalledExactlyOnceWith("vcA", "role1"); + expect(flashOf(result).get("level")).toBe("success"); + expect(result.redirect).toBe("/signups/roles/by-role/role1"); +}); From 14438e12c0dc3a01246596e649125f2d694f774e Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:31:34 -0400 Subject: [PATCH 55/82] make the options toggle a stateful gear instead of a back button Co-Authored-By: Claude Fable 5 --- src/routes/lib/components.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 94467a5..1af3f3c 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -146,14 +146,16 @@ export const pagedControls = ( }, ): APIActionRowComponent[] => { if (pageCount <= 1) return options.length > 0 ? [row(...options)] : []; + // the ⚙ toggle sits at the row's end in both modes, highlighted while the + // options are open; clicking it again returns to the page controls if (showOptions && options.length > 0) return [ row( + ...options, new RouteButtonBuilder(router) - .setLabel("◀ Back") - .setStyle(ButtonStyle.Secondary) + .setLabel("⚙ Options") + .setStyle(ButtonStyle.Primary) .setTo(basePath, { queryParams: { page: String(page) } }), - ...options, ), ]; const pageButton = (label: string, target: number, disabled: boolean) => From ea85135b0a51d568379f92d9fcf3dee1ebdad59c Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:32:53 -0400 Subject: [PATCH 56/82] lead the control row with the options toggle so it never shifts Co-Authored-By: Claude Fable 5 --- src/routes/lib/components.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 1af3f3c..f915151 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -146,16 +146,17 @@ export const pagedControls = ( }, ): APIActionRowComponent[] => { if (pageCount <= 1) return options.length > 0 ? [row(...options)] : []; - // the ⚙ toggle sits at the row's end in both modes, highlighted while the - // options are open; clicking it again returns to the page controls + // the ⚙ toggle leads the row in both modes — the leading slot is the only + // position variable-width neighbors can't shift — and is highlighted while + // the options are open; clicking it again returns to the page controls if (showOptions && options.length > 0) return [ row( - ...options, new RouteButtonBuilder(router) .setLabel("⚙ Options") .setStyle(ButtonStyle.Primary) .setTo(basePath, { queryParams: { page: String(page) } }), + ...options, ), ]; const pageButton = (label: string, target: number, disabled: boolean) => @@ -166,6 +167,16 @@ export const pagedControls = ( .setTo(basePath, { queryParams: { page: String(target) } }); return [ row( + ...(options.length > 0 + ? [ + new RouteButtonBuilder(router) + .setLabel("⚙ Options") + .setStyle(ButtonStyle.Secondary) + .setTo(basePath, { + queryParams: { page: String(page), options: "1" }, + }), + ] + : []), pageButton("◀ Prev", page - 1, page === 0), // the middle button doubles as a page jump: it opens a modal asking // which page to show, which redirects back to basePath @@ -181,16 +192,6 @@ export const pagedControls = ( }, }), pageButton("Next ▶", page + 1, page === pageCount - 1), - ...(options.length > 0 - ? [ - new RouteButtonBuilder(router) - .setLabel("⚙ Options") - .setStyle(ButtonStyle.Secondary) - .setTo(basePath, { - queryParams: { page: String(page), options: "1" }, - }), - ] - : []), ), ]; }; From 368ef6aae37de04c98e30ff55c97ea514510808f Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:41:41 -0400 Subject: [PATCH 57/82] open the options row with a back button instead of a lit toggle Co-Authored-By: Claude Fable 5 --- src/routes/lib/components.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index f915151..07d5e1d 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -146,15 +146,15 @@ export const pagedControls = ( }, ): APIActionRowComponent[] => { if (pageCount <= 1) return options.length > 0 ? [row(...options)] : []; - // the ⚙ toggle leads the row in both modes — the leading slot is the only - // position variable-width neighbors can't shift — and is highlighted while - // the options are open; clicking it again returns to the page controls + // the toggle leads the row in both modes — the leading slot is the only + // position variable-width neighbors can't shift; open options lead with + // the way back to the page controls if (showOptions && options.length > 0) return [ row( new RouteButtonBuilder(router) - .setLabel("⚙ Options") - .setStyle(ButtonStyle.Primary) + .setLabel("◀ Back") + .setStyle(ButtonStyle.Secondary) .setTo(basePath, { queryParams: { page: String(page) } }), ...options, ), From 73428045eaffb6dee5890e89a56b947d905efc27 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:42:50 -0400 Subject: [PATCH 58/82] carry the select-reset nonce as a component key instead of a query param The router strips the key during decode, so the POST handlers no longer receive a ts param they never read; base36 keeps the encoding short. Co-Authored-By: Claude Fable 5 --- src/routes/filter/[scope]/get.ts | 10 +++++----- src/routes/lib/paging.ts | 18 ++++++++++-------- src/routes/recipients/[scope]/get.ts | 10 +++++----- src/routes/signups/get.ts | 10 +++++----- src/routes/signups/roles/by-channel/get.ts | 10 +++++----- src/routes/signups/roles/by-role/get.ts | 10 +++++----- 6 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index 19c2a20..ebedab0 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -2,7 +2,7 @@ import { navBar, pagedControls, showOptionsOf } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { pagedCountLine, - pagedEditParams, + pagedEditPattern, paginate, SELECT_MAX_VALUES, } from "@routes/lib/paging"; @@ -69,10 +69,10 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { .setMaxValues(SELECT_MAX_VALUES) .setPlaceholder("Edit members: select to add, deselect to remove") .setDefaultUsers(...pageItems) - .setPattern(`${panelPath(scope)}/members`, { - method: "POST", - queryParams: pagedEditParams(page, state.timestamp), - }), + .setPattern( + `${panelPath(scope)}/members`, + pagedEditPattern(page, state.timestamp), + ), ) .toJSON(); diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts index 47d36e6..f842743 100644 --- a/src/routes/lib/paging.ts +++ b/src/routes/lib/paging.ts @@ -21,17 +21,19 @@ export const pagedCountLine = ( ): string => `**${label}** · ${total > 0 ? total : "None"}${pageCount > 1 ? ` across ${pageCount} pages` : ""}`; -// query params for a paged edit select's POST pattern. The ts nonce gives -// every render a fresh customId: the Discord client keeps a select's +// setPattern options for a paged edit select's POST. The per-render key +// gives every render a fresh customId: the Discord client keeps a select's // in-flight selection when a message edit leaves the component unchanged, -// so without it a submission's picks stay visibly selected instead of -// resetting to the page's default values -export const pagedEditParams = ( +// so without it a submission's picks would stay visibly selected instead +// of resetting to the page's default values +export const pagedEditPattern = ( page: number, timestamp: number, -): Record => ({ - page: String(page), - ts: String(timestamp), +): { method: "POST"; queryParams: Record; key: string } => ({ + method: "POST", + queryParams: { page: String(page) }, + // base36 for compactness; the key only needs to differ between renders + key: timestamp.toString(36), }); // slices one select-menu page out of a list. The last page is always diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index ca3e884..6ecfecf 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -8,7 +8,7 @@ import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; import { pagedCountLine, - pagedEditParams, + pagedEditPattern, paginate, SELECT_MAX_VALUES, } from "@routes/lib/paging"; @@ -106,10 +106,10 @@ export const recipientsGet: Handler<"GET"> = async ( .setMaxValues(SELECT_MAX_VALUES) .setPlaceholder("Edit ringees: select to add, deselect to remove") .setDefaultUsers(...pageItems) - .setPattern(`${panelPath(scope)}/members`, { - method: "POST", - queryParams: pagedEditParams(page, state.timestamp), - }), + .setPattern( + `${panelPath(scope)}/members`, + pagedEditPattern(page, state.timestamp), + ), ) .toJSON(); diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index f773d2c..9888453 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -7,7 +7,7 @@ import { import { withFlash } from "@routes/lib/flash"; import { pagedCountLine, - pagedEditParams, + pagedEditPattern, paginate, SELECT_MAX_VALUES, } from "@routes/lib/paging"; @@ -68,10 +68,10 @@ export const signupsGet: Handler<"GET"> = async ( .setMaxValues(SELECT_MAX_VALUES) .setPlaceholder("Edit signups: select to add, deselect to remove") .setDefaultChannels(...pageItems) - .setPattern(`${PANEL}/members`, { - method: "POST", - queryParams: pagedEditParams(page, state.timestamp), - }), + .setPattern( + `${PANEL}/members`, + pagedEditPattern(page, state.timestamp), + ), ) .toJSON(), ...pagedControls(router, PANEL, { diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index 83a15d8..a72417f 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -1,7 +1,7 @@ import { pagedControls, showOptionsOf } from "@routes/lib/components"; import { pagedCountLine, - pagedEditParams, + pagedEditPattern, paginate, SELECT_MAX_VALUES, } from "@routes/lib/paging"; @@ -72,10 +72,10 @@ export const rolesByChannelGet: Handler<"GET"> = async ( .setMaxValues(SELECT_MAX_VALUES) .setPlaceholder("Edit roles: select to add, deselect to remove") .setDefaultRoles(...pageItems) - .setPattern(`${BY_CHANNEL}/${scope}/roles`, { - method: "POST", - queryParams: pagedEditParams(page, state.timestamp), - }), + .setPattern( + `${BY_CHANNEL}/${scope}/roles`, + pagedEditPattern(page, state.timestamp), + ), ) .toJSON(); diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index 8e5543c..c888c0e 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -1,7 +1,7 @@ import { pagedControls, showOptionsOf } from "@routes/lib/components"; import { pagedCountLine, - pagedEditParams, + pagedEditPattern, paginate, SELECT_MAX_VALUES, } from "@routes/lib/paging"; @@ -77,10 +77,10 @@ export const rolesByRoleGet: Handler<"GET"> = async ( "Edit voice channels: select to add, deselect to remove", ) .setDefaultChannels(...pageItems) - .setPattern(`${BY_ROLE}/${scope}/channels`, { - method: "POST", - queryParams: pagedEditParams(page, state.timestamp), - }), + .setPattern( + `${BY_ROLE}/${scope}/channels`, + pagedEditPattern(page, state.timestamp), + ), ) .toJSON(); From aea839b51e5eb8d043d40696f3e34f8d32f9c23a Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:14:29 -0400 Subject: [PATCH 59/82] tighten paging, page-jump, ring guard, and unsignuprole confirmation - pageCountOf: only append a trailing empty add-page when a full page fills the whole edit select (PAGE_SIZE >= SELECT_MAX_VALUES); an exactly-full list now spans exactly its full pages, so the count line and pager no longer advertise a phantom page - page-jump: reject partly-numeric input ("2abc", "1e2") instead of honoring parseInt's prefix - ring: restore the server-only guard so /ring from a DM says so rather than the misleading "join a voice channel" hint - unsignuprole with no channel routes through the by-role reset confirmation modal instead of silently clearing every channel Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/unsignuprole.ts | 29 ++++++++++++------- src/routes/lib/paging.test.ts | 20 ++++++------- src/routes/lib/paging.ts | 19 ++++++++---- src/routes/page-jump/post.ts | 4 +-- src/routes/ring/_shared.ts | 10 ++++++- src/routes/ring/default/post.ts | 10 +++++-- src/routes/ring/get.ts | 4 +-- src/routes/ring/ring.test.ts | 21 ++++++++++++++ .../signups/roles/by-role/channels/post.ts | 6 +--- src/routes/signups/signups.test.ts | 20 ------------- 10 files changed, 86 insertions(+), 57 deletions(-) diff --git a/src/commands/unsignuprole.ts b/src/commands/unsignuprole.ts index 0421e6e..9f97a2b 100644 --- a/src/commands/unsignuprole.ts +++ b/src/commands/unsignuprole.ts @@ -28,15 +28,24 @@ export const unsignuprole = { async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { const role = interaction.options.getRole("role", true); const channel = interaction.options.getChannel("channel"); - // a channel removes just that link; no channel clears every channel - await router.dispatch( - interaction, - `/signups/roles/by-role/${role.id}/channels`, - { - method: "POST", - queryParams: channel ? { remove: channel.id } : { removeAll: "1" }, - flags: [MessageFlags.Ephemeral], - }, - ); + // a channel removes just that link; clearing every channel is + // destructive, so it goes through the same typed-confirmation modal as + // the panel's Reset button + if (channel) + await router.dispatch( + interaction, + `/signups/roles/by-role/${role.id}/channels`, + { + method: "POST", + queryParams: { remove: channel.id }, + flags: [MessageFlags.Ephemeral], + }, + ); + else + await router.dispatch( + interaction, + `/signups/roles/by-role/${role.id}/reset`, + { method: "MODAL", flags: [MessageFlags.Ephemeral] }, + ); }, }; diff --git a/src/routes/lib/paging.test.ts b/src/routes/lib/paging.test.ts index 16f11bd..ebf7176 100644 --- a/src/routes/lib/paging.test.ts +++ b/src/routes/lib/paging.test.ts @@ -17,11 +17,11 @@ test("a partial page fits one page with no pagination", () => { }); }); -test("an exactly full page is followed by an empty page for adding", () => { - expect(paginate(ids(PAGE_SIZE), "1")).toStrictEqual({ - pageItems: [], - page: 1, - pageCount: 2, +test("an exactly full page adds no trailing page while the select has room", () => { + expect(paginate(ids(PAGE_SIZE), null)).toStrictEqual({ + pageItems: ids(PAGE_SIZE), + page: 0, + pageCount: 1, }); }); @@ -34,12 +34,12 @@ test("lists over the page size split into pages of PAGE_SIZE", () => { }); }); -test("a full last page is followed by an empty page for adding", () => { +test("a list that is an exact multiple of the page size adds no trailing page", () => { const items = ids(PAGE_SIZE * 2); - expect(paginate(items, "2")).toStrictEqual({ - pageItems: [], - page: 2, - pageCount: 3, + expect(paginate(items, "1")).toStrictEqual({ + pageItems: items.slice(PAGE_SIZE), + page: 1, + pageCount: 2, }); }); diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts index f842743..0fbc00d 100644 --- a/src/routes/lib/paging.ts +++ b/src/routes/lib/paging.ts @@ -36,12 +36,21 @@ export const pagedEditPattern = ( key: timestamp.toString(36), }); -// slices one select-menu page out of a list. The last page is always -// partial (a length that is an exact multiple of PAGE_SIZE gets a trailing -// empty page), so adding stays possible even when every existing page is -// full; stale page indexes clamp to the last page +// how many pages a list of this size spans. A full last page only gets a +// trailing empty page when a page fills the whole edit select (PAGE_SIZE >= +// SELECT_MAX_VALUES), since otherwise the select still has room to add +// entries on the last page; an empty list still shows one page +export const pageCountOf = (total: number): number => { + const fullPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const needsAddPage = + PAGE_SIZE >= SELECT_MAX_VALUES && total > 0 && total % PAGE_SIZE === 0; + return fullPages + (needsAddPage ? 1 : 0); +}; + +// slices one select-menu page out of a list; stale page indexes clamp to +// the last page export const paginate = (items: string[], rawPage: string | null): Page => { - const pageCount = Math.floor(items.length / PAGE_SIZE) + 1; + const pageCount = pageCountOf(items.length); const parsed = parseInt(rawPage ?? "0"); const page = Math.min(Math.max(isNaN(parsed) ? 0 : parsed, 0), pageCount - 1); return { diff --git a/src/routes/page-jump/post.ts b/src/routes/page-jump/post.ts index c34dd29..cf13d5f 100644 --- a/src/routes/page-jump/post.ts +++ b/src/routes/page-jump/post.ts @@ -4,8 +4,8 @@ import { Handler } from "@routes/types"; export const pageJumpPost: Handler<"POST"> = (router, interaction, state) => { const query = state.queryParams; const to = query.get("to") ?? "/"; - const input = state.fields?.getTextInputValue("page") ?? ""; - const parsed = parseInt(input, 10); + const input = state.fields?.getTextInputValue("page")?.trim() ?? ""; + const parsed = /^\d+$/.test(input) ? Number(input) : NaN; const pageCount = parseInt(query.get("pageCount") ?? "1", 10) || 1; // a bad or out-of-range entry stays on the page the modal was opened from if (isNaN(parsed) || parsed < 1 || parsed > pageCount) diff --git a/src/routes/ring/_shared.ts b/src/routes/ring/_shared.ts index e36353e..181dc7c 100644 --- a/src/routes/ring/_shared.ts +++ b/src/routes/ring/_shared.ts @@ -12,6 +12,8 @@ import { export const PANEL = "/ring"; export const NOT_IN_VC = "RingVC needs to know which voice channel to ring people in. Join one, then run /ring again."; +export const SERVER_ONLY = + "RingVC can only ring people from within a Discord server. Run /ring in a server instead."; // panels outlive the voice state that was true at render time, so every // mutation re-checks it at click time @@ -22,6 +24,11 @@ export const voiceChannelOf = ( ? interaction.member.voice.channel : null; +// why there is no channel to ring in: server-only outside a guild, otherwise +// a prompt to join a voice channel +export const noVoiceChannelFlash = (interaction: Interaction): string => + interaction.inGuild() ? NOT_IN_VC : SERVER_ONLY; + export const ringResultsFlash = (results: UserRingResult[]) => { const ringed = results .filter((result) => result.status === "fulfilled") @@ -46,7 +53,8 @@ export const ringUserIds = async ( userIds: string[], ) => { const channel = voiceChannelOf(interaction); - if (!channel) return flashRedirect(PANEL, NOT_IN_VC, "warn"); + if (!channel) + return flashRedirect(PANEL, noVoiceChannelFlash(interaction), "warn"); if (userIds.length === 0) return flashRedirect(PANEL, "Nobody was selected to ring", "warn"); diff --git a/src/routes/ring/default/post.ts b/src/routes/ring/default/post.ts index 6575a88..bad873a 100644 --- a/src/routes/ring/default/post.ts +++ b/src/routes/ring/default/post.ts @@ -4,7 +4,12 @@ import { Handler } from "@routes/types"; import { getErrorMessage, ringDefaultUsers } from "@main/ring"; -import { NOT_IN_VC, PANEL, ringResultsFlash, voiceChannelOf } from "../_shared"; +import { + noVoiceChannelFlash, + PANEL, + ringResultsFlash, + voiceChannelOf, +} from "../_shared"; export const ringDefaultPost: Handler<"POST"> = async ( router, @@ -12,7 +17,8 @@ export const ringDefaultPost: Handler<"POST"> = async ( state, ) => { const channel = voiceChannelOf(interaction); - if (!channel) return flashRedirect(PANEL, NOT_IN_VC, "warn"); + if (!channel) + return flashRedirect(PANEL, noVoiceChannelFlash(interaction), "warn"); try { const results = await ringDefaultUsers( diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index d4e0103..94c0667 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -10,7 +10,7 @@ import { ActionRowBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; import { getAllDefaultRingees } from "@db/default-ringees"; -import { NOT_IN_VC, PANEL, voiceChannelOf } from "./_shared"; +import { noVoiceChannelFlash, PANEL, voiceChannelOf } from "./_shared"; const COLOR = "#c87a6d"; @@ -28,7 +28,7 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { new EmbedBuilder() .setColor(COLOR) .setTitle("📣 Quick ring") - .setDescription(`⚠️ ${NOT_IN_VC}`), + .setDescription(`⚠️ ${noVoiceChannelFlash(interaction)}`), ], components: [ ringViews, diff --git a/src/routes/ring/ring.test.ts b/src/routes/ring/ring.test.ts index b4a7a4a..6379337 100644 --- a/src/routes/ring/ring.test.ts +++ b/src/routes/ring/ring.test.ts @@ -19,6 +19,7 @@ const makeInteraction = (inVoice: boolean) => ({ user: { id: "caller" }, member: { voice: { channel: inVoice ? voiceChannel : null } }, + inGuild: () => true, }) as unknown as Interaction; const state = (query: string, values?: string[]) => @@ -50,6 +51,26 @@ test("ringing while not in a voice channel flashes the join hint and rings nobod expect(flashParams.get("flash")).toContain("voice channel"); }); +test("ringing from a DM flashes the server-only hint and rings nobody", async () => { + const dmInteraction = { + user: { id: "caller" }, + member: null, + inGuild: () => false, + } as unknown as Interaction; + const result = await ringUsersPost( + undefined as never, + dmInteraction, + state("", ["9"]), + ); + + expect(ring).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); + expect(flashParams.get("flash")).toContain("Discord server"); +}); + test("a selection rings the submitted users and reports per-user outcomes", async () => { vi.mocked(ring).mockResolvedValue([ { userId: "9", status: "fulfilled" }, diff --git a/src/routes/signups/roles/by-role/channels/post.ts b/src/routes/signups/roles/by-role/channels/post.ts index e456c39..eaa744b 100644 --- a/src/routes/signups/roles/by-role/channels/post.ts +++ b/src/routes/signups/roles/by-role/channels/post.ts @@ -22,8 +22,7 @@ import { // edits the channels one role is signed up to. Serves the panel's channel // multi-select (diffed against the shown page) and the /unsignuprole adapter -// (a `remove` query param, or `removeAll` to clear every channel); the scope -// is the role id in the path +// (a `remove` query param); the scope is the role id in the path export const rolesByRoleEditPost: Handler<"POST"> = async ( router, interaction, @@ -68,9 +67,6 @@ export const rolesByRoleEditPost: Handler<"POST"> = async ( pageItems, submitted: state.values, })); - } else if (query.get("removeAll")) { - addsRequested = []; - removesRequested = current; } else { addsRequested = query.getAll("add"); removesRequested = query.getAll("remove"); diff --git a/src/routes/signups/signups.test.ts b/src/routes/signups/signups.test.ts index 619da30..bf2c84c 100644 --- a/src/routes/signups/signups.test.ts +++ b/src/routes/signups/signups.test.ts @@ -13,7 +13,6 @@ import { import { signupsResetPost } from "./reset/post"; import { rolesByChannelResetPost } from "./roles/by-channel/reset/post"; import { rolesByChannelEditPost } from "./roles/by-channel/roles/post"; -import { rolesByRoleEditPost } from "./roles/by-role/channels/post"; import { rolesByRoleResetPost } from "./roles/by-role/reset/post"; vi.mock("@db/voice-chats", () => ({ @@ -123,25 +122,6 @@ test("a role edit without Manage Roles mutates nothing", async () => { expect(flashOf(result).get("flash")).toContain("Manage Roles"); }); -test("unsignuprole removeAll clears every channel the role is signed up to", async () => { - vi.mocked(getVoiceChatRoleSignups).mockResolvedValue([ - { roleId: "role1", channelId: "vcA" }, - { roleId: "role1", channelId: "vcB" }, - { roleId: "other", channelId: "vcC" }, - ]); - - const result = await rolesByRoleEditPost( - undefined as never, - makeInteraction(true), - editState({ scope: "role1", query: "removeAll=1" }), - ); - - expect(removeVoiceChatRole).toHaveBeenCalledWith("vcA", "role1"); - expect(removeVoiceChatRole).toHaveBeenCalledWith("vcB", "role1"); - expect(removeVoiceChatRole).not.toHaveBeenCalledWith("vcC", "role1"); - expect(result.redirect).toBe("/signups/roles/by-role/role1"); -}); - const resetState = (confirmation: string, scope?: string) => ({ params: scope === undefined ? {} : { scope }, From 573859c58113bdba86ce9c06c12c09213cdda0a2 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:24:54 -0400 Subject: [PATCH 60/82] consolidate role-signup edit mechanics into shared helpers - role edit handlers call the existing resolveSelectionEdit instead of hand-rolling the select/diff-or-query-param resolution - extract the guild + Manage-Roles + pick-a-scope guard the four role edit/reset handlers repeated into roleEditGuard, returning the resolved guild and scope or the flash to redirect with - move mentionChannel beside mentionUser/mentionRole in @main/ring so the mention family lives in one place Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/ring.ts | 1 + src/routes/signups/_shared.ts | 2 - src/routes/signups/members/post.ts | 4 +- src/routes/signups/roles/_shared.ts | 28 ++++++++- src/routes/signups/roles/by-channel/get.ts | 2 +- .../signups/roles/by-channel/reset/post.ts | 24 +++----- .../signups/roles/by-channel/roles/post.ts | 57 +++++------------- .../signups/roles/by-role/channels/post.ts | 58 +++++-------------- .../signups/roles/by-role/reset/post.ts | 25 +++----- 9 files changed, 78 insertions(+), 123 deletions(-) diff --git a/src/main/ring.ts b/src/main/ring.ts index a806213..41e2aee 100644 --- a/src/main/ring.ts +++ b/src/main/ring.ts @@ -17,6 +17,7 @@ import { getVoiceChatSignups } from "@db/voice-chats"; export const mentionUser = (userId: string) => `<@${userId}>`; export const mentionRole = (roleId: string) => `<@&${roleId}>`; +export const mentionChannel = (channelId: string) => `<#${channelId}>`; export const joinWithAnd = (list: string[]): string => { return list.length >= 2 diff --git a/src/routes/signups/_shared.ts b/src/routes/signups/_shared.ts index 72466ed..055b127 100644 --- a/src/routes/signups/_shared.ts +++ b/src/routes/signups/_shared.ts @@ -16,8 +16,6 @@ export const COLOR = "#31a5a5"; export const PANEL = "/signups"; export const ROLES = "/signups/roles"; -export const mentionChannel = (channelId: string) => `<#${channelId}>`; - export const canManageRoleSignups = (interaction: Interaction): boolean => interaction.memberPermissions?.has(PermissionsBitField.Flags.ManageRoles) === true || diff --git a/src/routes/signups/members/post.ts b/src/routes/signups/members/post.ts index d78034d..59eee31 100644 --- a/src/routes/signups/members/post.ts +++ b/src/routes/signups/members/post.ts @@ -7,9 +7,9 @@ import { import { Handler } from "@routes/types"; import { addVoiceChatUser, removeVoiceChatUser } from "@db/voice-chats"; -import { joinWithAnd } from "@main/ring"; +import { joinWithAnd, mentionChannel } from "@main/ring"; -import { guildSignups, mentionChannel, PANEL } from "../_shared"; +import { guildSignups, PANEL } from "../_shared"; // serves the panel's diff select and the /signup, /unsignup and /quit // adapters (which pass add/remove channel ids as query params) diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index f44a619..13c8feb 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -13,7 +13,7 @@ import { import { joinWithAnd } from "@main/ring"; -import { COLOR, PANEL, ROLES } from "../_shared"; +import { canManageRoleSignups, COLOR, PANEL, ROLES } from "../_shared"; export const BY_CHANNEL = "/signups/roles/by-channel"; export const BY_ROLE = "/signups/roles/by-role"; @@ -31,6 +31,32 @@ export const roleScopeOf = ( ? params.scope : null; +// the guard every role-signups mutation shares: rejects edits outside a +// guild, without Manage Roles, or before a scope is picked. On success +// returns the resolved guild and scope; otherwise the flash to redirect with +export const roleEditGuard = ( + interaction: Interaction, + params: Partial>, + { base, noun }: { base: string; noun: string }, +): { guild: Guild; scope: string } | RouteRedirect => { + const guild = interaction.guild; + if (!guild) + return flashRedirect( + base, + "Signups only work inside a Discord server", + "warn", + ); + if (!canManageRoleSignups(interaction)) + return flashRedirect( + base, + "You need the Manage Roles permission to manage role signups", + "warn", + ); + const scope = roleScopeOf(params); + if (!scope) return flashRedirect(base, `Pick a ${noun} first`, "warn"); + return { guild, scope }; +}; + // stable name ordering so a scope's links paginate the same way on the GET // that renders the select and the POST that diffs its submission export const sortRoleIds = (guild: Guild, ids: string[]): string[] => diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index a72417f..5603be3 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -14,6 +14,7 @@ import { import { ActionRowBuilder, ButtonStyle, ChannelType } from "discord.js"; import { getVoiceChatSignups } from "@db/voice-chats"; +import { mentionChannel } from "@main/ring"; import { BY_CHANNEL, @@ -25,7 +26,6 @@ import { import { canManageRoleSignups, guildOnlyRender, - mentionChannel, noPermissionRender, } from "../../_shared"; import { rolesGet } from "../get"; diff --git a/src/routes/signups/roles/by-channel/reset/post.ts b/src/routes/signups/roles/by-channel/reset/post.ts index c15b3fd..d96e043 100644 --- a/src/routes/signups/roles/by-channel/reset/post.ts +++ b/src/routes/signups/roles/by-channel/reset/post.ts @@ -3,9 +3,9 @@ import { flashRedirect } from "@routes/lib/flash"; import { Handler } from "@routes/types"; import { getVoiceChatSignups, removeVoiceChatRole } from "@db/voice-chats"; +import { mentionChannel } from "@main/ring"; -import { BY_CHANNEL, roleScopeOf } from "../../_shared"; -import { canManageRoleSignups, mentionChannel } from "../../../_shared"; +import { BY_CHANNEL, roleEditGuard } from "../../_shared"; // clears every role pinged in one voice channel export const rolesByChannelResetPost: Handler<"POST"> = async ( @@ -13,20 +13,12 @@ export const rolesByChannelResetPost: Handler<"POST"> = async ( interaction, state, ) => { - if (!interaction.guild) - return flashRedirect( - BY_CHANNEL, - "Signups only work inside a Discord server", - "warn", - ); - if (!canManageRoleSignups(interaction)) - return flashRedirect( - BY_CHANNEL, - "You need the Manage Roles permission to manage role signups", - "warn", - ); - const scope = roleScopeOf(state.params); - if (!scope) return flashRedirect(BY_CHANNEL, "Pick a channel first", "warn"); + const guard = roleEditGuard(interaction, state.params, { + base: BY_CHANNEL, + noun: "channel", + }); + if (!("guild" in guard)) return guard; + const { scope } = guard; const panel = `${BY_CHANNEL}/${scope}`; if (!confirmed(state.fields, "RESET")) diff --git a/src/routes/signups/roles/by-channel/roles/post.ts b/src/routes/signups/roles/by-channel/roles/post.ts index 5fe4451..39ee3c4 100644 --- a/src/routes/signups/roles/by-channel/roles/post.ts +++ b/src/routes/signups/roles/by-channel/roles/post.ts @@ -1,5 +1,4 @@ -import { flashRedirect } from "@routes/lib/flash"; -import { diffSelection, paginate } from "@routes/lib/paging"; +import { resolveSelectionEdit } from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { @@ -12,10 +11,9 @@ import { mentionRole } from "@main/ring"; import { BY_CHANNEL, commitRoleEdit, - roleScopeOf, + roleEditGuard, sortRoleIds, } from "../../_shared"; -import { canManageRoleSignups } from "../../../_shared"; // edits the roles signed up to one voice channel. Serves the panel's role // multi-select (diffed against the shown page) and the /signuprole adapter @@ -25,47 +23,22 @@ export const rolesByChannelEditPost: Handler<"POST"> = async ( interaction, state, ) => { - const guild = interaction.guild; - if (!guild) - return flashRedirect( - BY_CHANNEL, - "Signups only work inside a Discord server", - "warn", - ); - if (!canManageRoleSignups(interaction)) - return flashRedirect( - BY_CHANNEL, - "You need the Manage Roles permission to manage role signups", - "warn", - ); - - const scope = roleScopeOf(state.params); - if (!scope) return flashRedirect(BY_CHANNEL, "Pick a channel first", "warn"); + const guard = roleEditGuard(interaction, state.params, { + base: BY_CHANNEL, + noun: "channel", + }); + if (!("guild" in guard)) return guard; + const { guild, scope } = guard; const query = state.queryParams; - const current = sortRoleIds( - guild, - (await getVoiceChatSignups(scope)).roleIds, - ); + const current = sortRoleIds(guild, (await getVoiceChatSignups(scope)).roleIds); - let addsRequested: string[]; - let removesRequested: string[]; - let alreadyPresent: string[] = []; - if (state.values) { - const { pageItems } = paginate(current, query.get("page")); - ({ - added: addsRequested, - removed: removesRequested, - alreadyPresent, - } = diffSelection({ - allItems: current, - pageItems, - submitted: state.values, - })); - } else { - addsRequested = query.getAll("add"); - removesRequested = query.getAll("remove"); - } + const { addsRequested, removesRequested, alreadyPresent } = + resolveSelectionEdit({ + current, + values: state.values, + queryParams: query, + }); return commitRoleEdit({ redirect: `${BY_CHANNEL}/${scope}`, diff --git a/src/routes/signups/roles/by-role/channels/post.ts b/src/routes/signups/roles/by-role/channels/post.ts index eaa744b..1481a96 100644 --- a/src/routes/signups/roles/by-role/channels/post.ts +++ b/src/routes/signups/roles/by-role/channels/post.ts @@ -1,5 +1,4 @@ -import { flashRedirect } from "@routes/lib/flash"; -import { diffSelection, paginate } from "@routes/lib/paging"; +import { resolveSelectionEdit } from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { @@ -7,18 +6,15 @@ import { getVoiceChatRoleSignups, removeVoiceChatRole, } from "@db/voice-chats"; +import { mentionChannel } from "@main/ring"; import { BY_ROLE, commitRoleEdit, - roleScopeOf, + roleEditGuard, sortChannelIds, } from "../../_shared"; -import { - canManageRoleSignups, - guildVoiceChannelIds, - mentionChannel, -} from "../../../_shared"; +import { guildVoiceChannelIds } from "../../../_shared"; // edits the channels one role is signed up to. Serves the panel's channel // multi-select (diffed against the shown page) and the /unsignuprole adapter @@ -28,22 +24,12 @@ export const rolesByRoleEditPost: Handler<"POST"> = async ( interaction, state, ) => { - const guild = interaction.guild; - if (!guild) - return flashRedirect( - BY_ROLE, - "Signups only work inside a Discord server", - "warn", - ); - if (!canManageRoleSignups(interaction)) - return flashRedirect( - BY_ROLE, - "You need the Manage Roles permission to manage role signups", - "warn", - ); - - const scope = roleScopeOf(state.params); - if (!scope) return flashRedirect(BY_ROLE, "Pick a role first", "warn"); + const guard = roleEditGuard(interaction, state.params, { + base: BY_ROLE, + noun: "role", + }); + if (!("guild" in guard)) return guard; + const { guild, scope } = guard; const query = state.queryParams; const current = sortChannelIds( @@ -53,24 +39,12 @@ export const rolesByRoleEditPost: Handler<"POST"> = async ( .map((mapping) => mapping.channelId), ); - let addsRequested: string[]; - let removesRequested: string[]; - let alreadyPresent: string[] = []; - if (state.values) { - const { pageItems } = paginate(current, query.get("page")); - ({ - added: addsRequested, - removed: removesRequested, - alreadyPresent, - } = diffSelection({ - allItems: current, - pageItems, - submitted: state.values, - })); - } else { - addsRequested = query.getAll("add"); - removesRequested = query.getAll("remove"); - } + const { addsRequested, removesRequested, alreadyPresent } = + resolveSelectionEdit({ + current, + values: state.values, + queryParams: query, + }); return commitRoleEdit({ redirect: `${BY_ROLE}/${scope}`, diff --git a/src/routes/signups/roles/by-role/reset/post.ts b/src/routes/signups/roles/by-role/reset/post.ts index 6652825..cce2156 100644 --- a/src/routes/signups/roles/by-role/reset/post.ts +++ b/src/routes/signups/roles/by-role/reset/post.ts @@ -5,8 +5,8 @@ import { Handler } from "@routes/types"; import { getVoiceChatRoleSignups, removeVoiceChatRole } from "@db/voice-chats"; import { mentionRole } from "@main/ring"; -import { BY_ROLE, roleScopeOf } from "../../_shared"; -import { canManageRoleSignups, guildVoiceChannelIds } from "../../../_shared"; +import { BY_ROLE, roleEditGuard } from "../../_shared"; +import { guildVoiceChannelIds } from "../../../_shared"; // clears every voice channel one role is signed up for export const rolesByRoleResetPost: Handler<"POST"> = async ( @@ -14,21 +14,12 @@ export const rolesByRoleResetPost: Handler<"POST"> = async ( interaction, state, ) => { - const guild = interaction.guild; - if (!guild) - return flashRedirect( - BY_ROLE, - "Signups only work inside a Discord server", - "warn", - ); - if (!canManageRoleSignups(interaction)) - return flashRedirect( - BY_ROLE, - "You need the Manage Roles permission to manage role signups", - "warn", - ); - const scope = roleScopeOf(state.params); - if (!scope) return flashRedirect(BY_ROLE, "Pick a role first", "warn"); + const guard = roleEditGuard(interaction, state.params, { + base: BY_ROLE, + noun: "role", + }); + if (!("guild" in guard)) return guard; + const { guild, scope } = guard; const panel = `${BY_ROLE}/${scope}`; if (!confirmed(state.fields, "RESET")) From a7dc13599b94b631d411b042cd7cc05c303ce120 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:33:27 -0400 Subject: [PATCH 61/82] extract the paged edit-select row into a shared components helper The four member panels built the same add/remove select by hand, varying only the entity type, its default entries, and the noun. editSelectRow takes the caller's entity-specific builder plus that noun and applies the shared min/max, "Edit : select to add, deselect to remove" placeholder, reset key, and pattern wiring. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/filter/[scope]/get.ts | 36 +++++++++---------- src/routes/lib/components.ts | 37 +++++++++++++++++++- src/routes/recipients/[scope]/get.ts | 30 ++++++---------- src/routes/signups/roles/by-channel/get.ts | 35 ++++++++----------- src/routes/signups/roles/by-role/get.ts | 40 +++++++++------------- 5 files changed, 95 insertions(+), 83 deletions(-) diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index ebedab0..4e96546 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -1,11 +1,11 @@ -import { navBar, pagedControls, showOptionsOf } from "@routes/lib/components"; -import { withFlash } from "@routes/lib/flash"; import { - pagedCountLine, - pagedEditPattern, - paginate, - SELECT_MAX_VALUES, -} from "@routes/lib/paging"; + editSelectRow, + navBar, + pagedControls, + showOptionsOf, +} from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { pagedCountLine, paginate } from "@routes/lib/paging"; import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; import { @@ -62,19 +62,15 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { ) .toJSON(); - const editSelect = new ActionRowBuilder() - .addComponents( - new RouteUserSelectMenuBuilder(router) - .setMinValues(0) - .setMaxValues(SELECT_MAX_VALUES) - .setPlaceholder("Edit members: select to add, deselect to remove") - .setDefaultUsers(...pageItems) - .setPattern( - `${panelPath(scope)}/members`, - pagedEditPattern(page, state.timestamp), - ), - ) - .toJSON(); + const editSelect = editSelectRow( + new RouteUserSelectMenuBuilder(router).setDefaultUsers(...pageItems), + { + noun: "members", + pattern: `${panelPath(scope)}/members`, + page, + timestamp: state.timestamp, + }, + ); const filterOptions = [ new RouteButtonBuilder(router) diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 07d5e1d..db9651f 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -1,11 +1,18 @@ import { buttonEmoji, VC_EMOJI_ID } from "@routes/lib/emoji"; -import { Page } from "@routes/lib/paging"; +import { + Page, + pagedEditPattern, + SELECT_MAX_VALUES, +} from "@routes/lib/paging"; import { PAGE_JUMP } from "@routes/page-jump/_shared"; import { RingButton, RingRouter } from "@routes/types"; import { RouteButtonBuilder, + RouteChannelSelectMenuBuilder, + RouteRoleSelectMenuBuilder, RouteStringSelectMenuBuilder, RouteStringSelectMenuOptionBuilder, + RouteUserSelectMenuBuilder, } from "discord-embed-router"; import { ActionRowBuilder, @@ -21,6 +28,34 @@ export const row = ( ): APIActionRowComponent => new ActionRowBuilder().addComponents(...components).toJSON(); +// the add/remove select every paged member panel shares: the page's entries +// ride in as the caller-set defaults, and selecting or deselecting them posts +// the diff. The caller supplies the entity-specific builder (with its default +// entries and any channel-type filter already set) and the noun the shared +// placeholder reads; the row-reset key keeps a stale client selection from +// surviving the next render +export const editSelectRow = < + B extends + | RouteUserSelectMenuBuilder + | RouteRoleSelectMenuBuilder + | RouteChannelSelectMenuBuilder, +>( + builder: B, + { + noun, + pattern, + page, + timestamp, + }: { noun: string; pattern: string; page: number; timestamp: number }, +): APIActionRowComponent => { + builder + .setMinValues(0) + .setMaxValues(SELECT_MAX_VALUES) + .setPlaceholder(`Edit ${noun}: select to add, deselect to remove`) + .setPattern(pattern, pagedEditPattern(page, timestamp)); + return new ActionRowBuilder().addComponents(builder).toJSON(); +}; + export const homeButton = (router: RingRouter): RingButton => new RouteButtonBuilder(router) .setLabel("🏠 Home") diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 6ecfecf..f6b8dc6 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -1,4 +1,5 @@ import { + editSelectRow, navBar, pagedControls, showOptionsOf, @@ -6,12 +7,7 @@ import { } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; -import { - pagedCountLine, - pagedEditPattern, - paginate, - SELECT_MAX_VALUES, -} from "@routes/lib/paging"; +import { pagedCountLine, paginate } from "@routes/lib/paging"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; import { @@ -99,19 +95,15 @@ export const recipientsGet: Handler<"GET"> = async ( ) .toJSON(); - const editSelect = new ActionRowBuilder() - .addComponents( - new RouteUserSelectMenuBuilder(router) - .setMinValues(0) - .setMaxValues(SELECT_MAX_VALUES) - .setPlaceholder("Edit ringees: select to add, deselect to remove") - .setDefaultUsers(...pageItems) - .setPattern( - `${panelPath(scope)}/members`, - pagedEditPattern(page, state.timestamp), - ), - ) - .toJSON(); + const editSelect = editSelectRow( + new RouteUserSelectMenuBuilder(router).setDefaultUsers(...pageItems), + { + noun: "ringees", + pattern: `${panelPath(scope)}/members`, + page, + timestamp: state.timestamp, + }, + ); const ringOptions = [ new RouteButtonBuilder(router) diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index 5603be3..cae6a08 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -1,10 +1,9 @@ -import { pagedControls, showOptionsOf } from "@routes/lib/components"; import { - pagedCountLine, - pagedEditPattern, - paginate, - SELECT_MAX_VALUES, -} from "@routes/lib/paging"; + editSelectRow, + pagedControls, + showOptionsOf, +} from "@routes/lib/components"; +import { pagedCountLine, paginate } from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { RouteButtonBuilder, @@ -65,19 +64,15 @@ export const rolesByChannelGet: Handler<"GET"> = async ( ) .toJSON(); - const editSelectRow = new ActionRowBuilder() - .addComponents( - new RouteRoleSelectMenuBuilder(router) - .setMinValues(0) - .setMaxValues(SELECT_MAX_VALUES) - .setPlaceholder("Edit roles: select to add, deselect to remove") - .setDefaultRoles(...pageItems) - .setPattern( - `${BY_CHANNEL}/${scope}/roles`, - pagedEditPattern(page, state.timestamp), - ), - ) - .toJSON(); + const editSelect = editSelectRow( + new RouteRoleSelectMenuBuilder(router).setDefaultRoles(...pageItems), + { + noun: "roles", + pattern: `${BY_CHANNEL}/${scope}/roles`, + page, + timestamp: state.timestamp, + }, + ); return roleFrame({ router, @@ -90,7 +85,7 @@ export const rolesByChannelGet: Handler<"GET"> = async ( // pager; the switch and section bar follow in the frame rows: [ scopeSelectRow, - editSelectRow, + editSelect, ...pagedControls(router, `${BY_CHANNEL}/${scope}`, { page, pageCount, diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index c888c0e..24d2e50 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -1,10 +1,9 @@ -import { pagedControls, showOptionsOf } from "@routes/lib/components"; import { - pagedCountLine, - pagedEditPattern, - paginate, - SELECT_MAX_VALUES, -} from "@routes/lib/paging"; + editSelectRow, + pagedControls, + showOptionsOf, +} from "@routes/lib/components"; +import { pagedCountLine, paginate } from "@routes/lib/paging"; import { Handler } from "@routes/types"; import { RouteButtonBuilder, @@ -67,22 +66,17 @@ export const rolesByRoleGet: Handler<"GET"> = async ( ) .toJSON(); - const editSelectRow = new ActionRowBuilder() - .addComponents( - new RouteChannelSelectMenuBuilder(router) - .setChannelTypes(ChannelType.GuildVoice) - .setMinValues(0) - .setMaxValues(SELECT_MAX_VALUES) - .setPlaceholder( - "Edit voice channels: select to add, deselect to remove", - ) - .setDefaultChannels(...pageItems) - .setPattern( - `${BY_ROLE}/${scope}/channels`, - pagedEditPattern(page, state.timestamp), - ), - ) - .toJSON(); + const editSelect = editSelectRow( + new RouteChannelSelectMenuBuilder(router) + .setChannelTypes(ChannelType.GuildVoice) + .setDefaultChannels(...pageItems), + { + noun: "voice channels", + pattern: `${BY_ROLE}/${scope}/channels`, + page, + timestamp: state.timestamp, + }, + ); return roleFrame({ router, @@ -95,7 +89,7 @@ export const rolesByRoleGet: Handler<"GET"> = async ( // pager; the switch and section bar follow in the frame rows: [ scopeSelectRow, - editSelectRow, + editSelect, ...pagedControls(router, `${BY_ROLE}/${scope}`, { page, pageCount, From ea972b691e9f98622fa87a8c3e009188e543d2f0 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:30:32 -0400 Subject: [PATCH 62/82] update prettier import order --- .prettierrc | 2 +- src/commands/catalog.ts | 3 ++- src/commands/commands.ts | 2 +- src/commands/defaultRingRecipient.ts | 3 ++- src/commands/deleteData.ts | 3 ++- src/commands/filter/block.ts | 3 ++- src/commands/filter/filter.ts | 3 ++- src/commands/filter/unblock.ts | 3 ++- src/commands/filter/unwhitelist.ts | 3 ++- src/commands/filter/whitelist.ts | 3 ++- src/commands/help.ts | 3 ++- src/commands/mode.ts | 3 ++- src/commands/quit.ts | 2 +- src/commands/ring.ts | 3 ++- src/commands/ringDefaults.ts | 3 ++- src/commands/ringvc.ts | 3 ++- src/commands/signup.ts | 3 ++- src/commands/signuprole.ts | 3 ++- src/commands/unsignup.ts | 3 ++- src/commands/unsignuprole.ts | 3 ++- src/index.ts | 4 ++-- src/routes/about/get.ts | 3 ++- src/routes/delete-data/get.ts | 5 ++-- src/routes/delete-data/post.ts | 3 +-- src/routes/filter/[scope]/get.ts | 20 ++++++++-------- src/routes/filter/[scope]/members/post.ts | 15 ++++++------ src/routes/filter/[scope]/reset/post.ts | 3 +-- src/routes/filter/[scope]/type/post.ts | 3 +-- src/routes/get.ts | 3 ++- src/routes/help/catalog/get.ts | 6 ++--- src/routes/help/get.ts | 6 ++--- src/routes/lib/components.ts | 13 ++++------ src/routes/lib/confirm.ts | 3 ++- src/routes/lib/flash.test.ts | 3 ++- src/routes/lib/mentions.ts | 3 +-- src/routes/lib/paging.test.ts | 3 ++- src/routes/mode/get.ts | 6 ++--- src/routes/mode/post.ts | 3 +-- src/routes/page-jump/modal.ts | 3 ++- .../recipients/[scope]/auto-ring/post.ts | 3 +-- src/routes/recipients/[scope]/get.ts | 24 +++++++++---------- src/routes/recipients/[scope]/members/post.ts | 13 +++++----- src/routes/recipients/[scope]/reset/post.ts | 3 +-- src/routes/ring/_shared.ts | 2 +- src/routes/ring/default/post.ts | 3 +-- src/routes/ring/get.ts | 8 +++---- src/routes/signups/_shared.ts | 4 ++-- src/routes/signups/get.ts | 21 ++++++++-------- src/routes/signups/members/post.ts | 5 ++-- src/routes/signups/reset/post.ts | 3 +-- src/routes/signups/roles/_shared.ts | 8 +++---- src/routes/signups/roles/by-channel/get.ts | 14 +++++------ .../signups/roles/by-channel/reset/post.ts | 5 ++-- .../signups/roles/by-channel/roles/post.ts | 10 ++++---- .../signups/roles/by-role/channels/post.ts | 5 ++-- src/routes/signups/roles/by-role/get.ts | 14 +++++------ .../signups/roles/by-role/reset/post.ts | 5 ++-- src/routes/signups/roles/get.ts | 3 ++- 58 files changed, 164 insertions(+), 154 deletions(-) diff --git a/.prettierrc b/.prettierrc index ff2f7e4..b10c283 100644 --- a/.prettierrc +++ b/.prettierrc @@ -6,7 +6,7 @@ "", "", "", - "^@(config$|commands/|db/|main/)", + "^@(config$|commands/|db/|main/|routes/)", "", "^[./]" ] diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 5036193..2d41b19 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -1,10 +1,11 @@ -import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const catalog = { data: new SlashCommandBuilder() .setName("catalog") diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 51ee554..36c3af6 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,4 +1,3 @@ -import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, SharedSlashCommand } from "discord.js"; import { catalog } from "@commands/catalog"; @@ -19,6 +18,7 @@ import { signup } from "@commands/signup"; import { signuprole } from "@commands/signuprole"; import { unsignup } from "@commands/unsignup"; import { unsignuprole } from "@commands/unsignuprole"; +import { RingRouter } from "@routes/types"; // commands are thin adapters: they parse their options and dispatch into // the router, where all business rules live diff --git a/src/commands/defaultRingRecipient.ts b/src/commands/defaultRingRecipient.ts index 5e0e05a..3a741d7 100644 --- a/src/commands/defaultRingRecipient.ts +++ b/src/commands/defaultRingRecipient.ts @@ -1,4 +1,3 @@ -import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -6,6 +5,8 @@ import { SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const defaultRingRecipients = { data: new SlashCommandBuilder() .setName("default_ring_recipients") diff --git a/src/commands/deleteData.ts b/src/commands/deleteData.ts index 46bb06a..b59b1f2 100644 --- a/src/commands/deleteData.ts +++ b/src/commands/deleteData.ts @@ -1,10 +1,11 @@ -import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const deleteData = { data: new SlashCommandBuilder() .setName("delete_data") diff --git a/src/commands/filter/block.ts b/src/commands/filter/block.ts index 3450a1a..3ac150e 100644 --- a/src/commands/filter/block.ts +++ b/src/commands/filter/block.ts @@ -1,10 +1,11 @@ -import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const block = { data: new SlashCommandBuilder() .setName("block") diff --git a/src/commands/filter/filter.ts b/src/commands/filter/filter.ts index 3214891..eeb8faa 100644 --- a/src/commands/filter/filter.ts +++ b/src/commands/filter/filter.ts @@ -1,4 +1,3 @@ -import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -6,6 +5,8 @@ import { SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const filter = { data: new SlashCommandBuilder() .setName("filter") diff --git a/src/commands/filter/unblock.ts b/src/commands/filter/unblock.ts index db71e5e..33914e4 100644 --- a/src/commands/filter/unblock.ts +++ b/src/commands/filter/unblock.ts @@ -1,10 +1,11 @@ -import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const unblock = { data: new SlashCommandBuilder() .setName("unblock") diff --git a/src/commands/filter/unwhitelist.ts b/src/commands/filter/unwhitelist.ts index f05e898..3579af5 100644 --- a/src/commands/filter/unwhitelist.ts +++ b/src/commands/filter/unwhitelist.ts @@ -1,10 +1,11 @@ -import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const unwhitelist = { data: new SlashCommandBuilder() .setName("unwhitelist") diff --git a/src/commands/filter/whitelist.ts b/src/commands/filter/whitelist.ts index 3350d48..f5735a9 100644 --- a/src/commands/filter/whitelist.ts +++ b/src/commands/filter/whitelist.ts @@ -1,10 +1,11 @@ -import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const whitelist = { data: new SlashCommandBuilder() .setName("whitelist") diff --git a/src/commands/help.ts b/src/commands/help.ts index ebe535a..b6c9e18 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,10 +1,11 @@ -import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const help = { data: new SlashCommandBuilder() .setName("help") diff --git a/src/commands/mode.ts b/src/commands/mode.ts index 237c1b5..9b8f5e2 100644 --- a/src/commands/mode.ts +++ b/src/commands/mode.ts @@ -1,10 +1,11 @@ -import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const mode = { data: new SlashCommandBuilder() .setName("mode") diff --git a/src/commands/quit.ts b/src/commands/quit.ts index 8a69aa9..9406ac8 100644 --- a/src/commands/quit.ts +++ b/src/commands/quit.ts @@ -1,5 +1,4 @@ // the same behavior as unsignup, under the name people reach for -import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -7,6 +6,7 @@ import { } from "discord.js"; import { unsignup } from "@commands/unsignup"; +import { RingRouter } from "@routes/types"; export const quit = { data: new SlashCommandBuilder() diff --git a/src/commands/ring.ts b/src/commands/ring.ts index ea6b9dd..aa51f45 100644 --- a/src/commands/ring.ts +++ b/src/commands/ring.ts @@ -1,10 +1,11 @@ -import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const ring = { data: new SlashCommandBuilder() .setName("ring") diff --git a/src/commands/ringDefaults.ts b/src/commands/ringDefaults.ts index 125d3fc..2471cc6 100644 --- a/src/commands/ringDefaults.ts +++ b/src/commands/ringDefaults.ts @@ -1,10 +1,11 @@ -import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const ringDefaults = { data: new SlashCommandBuilder() .setName("ring_defaults") diff --git a/src/commands/ringvc.ts b/src/commands/ringvc.ts index eb9cbc3..0a84c74 100644 --- a/src/commands/ringvc.ts +++ b/src/commands/ringvc.ts @@ -1,10 +1,11 @@ -import { RingRouter } from "@routes/types"; import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const ringvc = { data: new SlashCommandBuilder() .setName("ringvc") diff --git a/src/commands/signup.ts b/src/commands/signup.ts index 7740e23..2c9a226 100644 --- a/src/commands/signup.ts +++ b/src/commands/signup.ts @@ -1,4 +1,3 @@ -import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -6,6 +5,8 @@ import { SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const signup = { data: new SlashCommandBuilder() .setName("signup") diff --git a/src/commands/signuprole.ts b/src/commands/signuprole.ts index 5016efb..4a87cbf 100644 --- a/src/commands/signuprole.ts +++ b/src/commands/signuprole.ts @@ -1,4 +1,3 @@ -import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -6,6 +5,8 @@ import { SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const signuprole = { data: new SlashCommandBuilder() .setName("signuprole") diff --git a/src/commands/unsignup.ts b/src/commands/unsignup.ts index 350031e..9e20cb0 100644 --- a/src/commands/unsignup.ts +++ b/src/commands/unsignup.ts @@ -1,4 +1,3 @@ -import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -6,6 +5,8 @@ import { SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const unsignup = { data: new SlashCommandBuilder() .setName("unsignup") diff --git a/src/commands/unsignuprole.ts b/src/commands/unsignuprole.ts index 9f97a2b..9f9e3e6 100644 --- a/src/commands/unsignuprole.ts +++ b/src/commands/unsignuprole.ts @@ -1,4 +1,3 @@ -import { RingRouter } from "@routes/types"; import { ChannelType, ChatInputCommandInteraction, @@ -6,6 +5,8 @@ import { SlashCommandBuilder, } from "discord.js"; +import { RingRouter } from "@routes/types"; + export const unsignuprole = { data: new SlashCommandBuilder() .setName("unsignuprole") diff --git a/src/index.ts b/src/index.ts index 848d47f..cb3e37c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -import { registerRoutes } from "@routes/index"; -import { Globals } from "@routes/types"; import { EmbedRouter } from "discord-embed-router"; import { ChatInputCommandInteraction, @@ -17,6 +15,8 @@ import { } from "@commands/commands"; import { DISCORD_TOKEN } from "@config"; import { onVoiceChannelJoin } from "@main/ring"; +import { registerRoutes } from "@routes/index"; +import { Globals } from "@routes/types"; const client = new Client({ intents: [ diff --git a/src/routes/about/get.ts b/src/routes/about/get.ts index 1f896c2..354a1c8 100644 --- a/src/routes/about/get.ts +++ b/src/routes/about/get.ts @@ -1,7 +1,8 @@ +import { ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; + import { navBar, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { Handler } from "@routes/types"; -import { ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; const COLOR = "#5865f2"; diff --git a/src/routes/delete-data/get.ts b/src/routes/delete-data/get.ts index 953391c..d75fc20 100644 --- a/src/routes/delete-data/get.ts +++ b/src/routes/delete-data/get.ts @@ -1,8 +1,9 @@ +import { RouteButtonBuilder } from "discord-embed-router"; +import { ButtonStyle, EmbedBuilder } from "discord.js"; + import { navBar, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { Handler } from "@routes/types"; -import { RouteButtonBuilder } from "discord-embed-router"; -import { ButtonStyle, EmbedBuilder } from "discord.js"; import { PANEL } from "./_shared"; diff --git a/src/routes/delete-data/post.ts b/src/routes/delete-data/post.ts index b0d10f4..d39d1be 100644 --- a/src/routes/delete-data/post.ts +++ b/src/routes/delete-data/post.ts @@ -1,9 +1,8 @@ +import { deleteAllUserData } from "@db/users"; import { confirmed } from "@routes/lib/confirm"; import { flashRedirect } from "@routes/lib/flash"; import { Handler } from "@routes/types"; -import { deleteAllUserData } from "@db/users"; - import { CONFIRMATION, PANEL } from "./_shared"; export const deleteDataPost: Handler<"POST"> = async ( diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index 4e96546..e7dfe0c 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -1,13 +1,3 @@ -import { - editSelectRow, - navBar, - pagedControls, - showOptionsOf, -} from "@routes/lib/components"; -import { withFlash } from "@routes/lib/flash"; -import { pagedCountLine, paginate } from "@routes/lib/paging"; -import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; -import { Handler } from "@routes/types"; import { RouteButtonBuilder, RouteChannelSelectMenuBuilder, @@ -21,6 +11,16 @@ import { } from "discord.js"; import { filterType, getFilter } from "@db/filters"; +import { + editSelectRow, + navBar, + pagedControls, + showOptionsOf, +} from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { pagedCountLine, paginate } from "@routes/lib/paging"; +import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; import { panelPath } from "../_shared"; diff --git a/src/routes/filter/[scope]/members/post.ts b/src/routes/filter/[scope]/members/post.ts index cb359d1..f9c7bc2 100644 --- a/src/routes/filter/[scope]/members/post.ts +++ b/src/routes/filter/[scope]/members/post.ts @@ -1,3 +1,10 @@ +import { + addFilterEntry, + filterType, + getFilter, + removeFilterEntry, +} from "@db/filters"; +import { joinWithAnd, mentionUser } from "@main/ring"; import { flashRedirect } from "@routes/lib/flash"; import { paginate, @@ -7,14 +14,6 @@ import { import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; -import { - addFilterEntry, - filterType, - getFilter, - removeFilterEntry, -} from "@db/filters"; -import { joinWithAnd, mentionUser } from "@main/ring"; - import { panelPath } from "../../_shared"; // one members editor serves the panel select and the /block, /unblock, diff --git a/src/routes/filter/[scope]/reset/post.ts b/src/routes/filter/[scope]/reset/post.ts index 683abfa..627a34e 100644 --- a/src/routes/filter/[scope]/reset/post.ts +++ b/src/routes/filter/[scope]/reset/post.ts @@ -1,10 +1,9 @@ +import { resetFilter } from "@db/filters"; import { confirmed } from "@routes/lib/confirm"; import { flashRedirect } from "@routes/lib/flash"; import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; -import { resetFilter } from "@db/filters"; - import { panelPath } from "../../_shared"; export const filterResetPost: Handler<"POST"> = async ( diff --git a/src/routes/filter/[scope]/type/post.ts b/src/routes/filter/[scope]/type/post.ts index f10d2b5..6c1b40d 100644 --- a/src/routes/filter/[scope]/type/post.ts +++ b/src/routes/filter/[scope]/type/post.ts @@ -1,9 +1,8 @@ +import { filterType, getFilter, setFilterType } from "@db/filters"; import { flashRedirect } from "@routes/lib/flash"; import { channelIdOf, scopeName, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; -import { filterType, getFilter, setFilterType } from "@db/filters"; - import { panelPath } from "../../_shared"; export const filterTypePost: Handler<"POST"> = async ( diff --git a/src/routes/get.ts b/src/routes/get.ts index aeea1d6..4ebd6c2 100644 --- a/src/routes/get.ts +++ b/src/routes/get.ts @@ -1,8 +1,9 @@ +import { EmbedBuilder } from "discord.js"; + import { navBar } from "@routes/lib/components"; import { emojiIconURL, RINGVC_EMOJI_ID } from "@routes/lib/emoji"; import { withFlash } from "@routes/lib/flash"; import { Handler } from "@routes/types"; -import { EmbedBuilder } from "discord.js"; import { COLOR } from "./_shared"; diff --git a/src/routes/help/catalog/get.ts b/src/routes/help/catalog/get.ts index 47517f9..e949ed8 100644 --- a/src/routes/help/catalog/get.ts +++ b/src/routes/help/catalog/get.ts @@ -1,10 +1,10 @@ +import { EmbedBuilder } from "discord.js"; + +import { CommandName } from "@commands/commandNames"; import { navBar } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; import { Handler } from "@routes/types"; -import { EmbedBuilder } from "discord.js"; - -import { CommandName } from "@commands/commandNames"; import { COLOR, helpSubNav } from "../_shared"; import { canManageRoleSignups } from "../../signups/_shared"; diff --git a/src/routes/help/get.ts b/src/routes/help/get.ts index 0988be6..bc088f9 100644 --- a/src/routes/help/get.ts +++ b/src/routes/help/get.ts @@ -1,10 +1,10 @@ +import { EmbedBuilder } from "discord.js"; + +import { CommandName } from "@commands/commandNames"; import { navBar } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; import { Handler } from "@routes/types"; -import { EmbedBuilder } from "discord.js"; - -import { CommandName } from "@commands/commandNames"; import { canManageRoleSignups } from "../signups/_shared"; import { COLOR, helpSubNav } from "./_shared"; diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index db9651f..517bc5f 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -1,11 +1,3 @@ -import { buttonEmoji, VC_EMOJI_ID } from "@routes/lib/emoji"; -import { - Page, - pagedEditPattern, - SELECT_MAX_VALUES, -} from "@routes/lib/paging"; -import { PAGE_JUMP } from "@routes/page-jump/_shared"; -import { RingButton, RingRouter } from "@routes/types"; import { RouteButtonBuilder, RouteChannelSelectMenuBuilder, @@ -23,6 +15,11 @@ import { Interaction, } from "discord.js"; +import { buttonEmoji, VC_EMOJI_ID } from "@routes/lib/emoji"; +import { Page, pagedEditPattern, SELECT_MAX_VALUES } from "@routes/lib/paging"; +import { PAGE_JUMP } from "@routes/page-jump/_shared"; +import { RingButton, RingRouter } from "@routes/types"; + export const row = ( ...components: ButtonBuilder[] ): APIActionRowComponent => diff --git a/src/routes/lib/confirm.ts b/src/routes/lib/confirm.ts index 89e1a6a..ea82f0a 100644 --- a/src/routes/lib/confirm.ts +++ b/src/routes/lib/confirm.ts @@ -1,7 +1,8 @@ -import { RingModal, RingRouter } from "@routes/types"; import { RouteModalBuilder } from "discord-embed-router"; import { LabelBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; +import { RingModal, RingRouter } from "@routes/types"; + // typed-word confirmation for destructive actions: a button opens this modal // with method MODAL, and the POST it targets verifies the input via confirmed() export const confirmModal = ( diff --git a/src/routes/lib/flash.test.ts b/src/routes/lib/flash.test.ts index ba64924..f28ce1f 100644 --- a/src/routes/lib/flash.test.ts +++ b/src/routes/lib/flash.test.ts @@ -1,6 +1,7 @@ -import { flashLine, flashRedirect } from "@routes/lib/flash"; import { expect, test } from "vitest"; +import { flashLine, flashRedirect } from "@routes/lib/flash"; + test("flashRedirect carries the flash text and level as redirect query params", () => { expect( flashRedirect("/filter/global", "Blocked <@1>", "success"), diff --git a/src/routes/lib/mentions.ts b/src/routes/lib/mentions.ts index 22ba509..3169c81 100644 --- a/src/routes/lib/mentions.ts +++ b/src/routes/lib/mentions.ts @@ -1,6 +1,5 @@ -import { Globals } from "@routes/types"; - import { CommandName } from "@commands/commandNames"; +import { Globals } from "@routes/types"; // a clickable command mention when the deployed id is known, plain text // otherwise (ids are fetched after the client is ready, so early diff --git a/src/routes/lib/paging.test.ts b/src/routes/lib/paging.test.ts index ebf7176..f7badca 100644 --- a/src/routes/lib/paging.test.ts +++ b/src/routes/lib/paging.test.ts @@ -1,10 +1,11 @@ +import { expect, test } from "vitest"; + import { diffSelection, PAGE_SIZE, paginate, withPageLabel, } from "@routes/lib/paging"; -import { expect, test } from "vitest"; const ids = (count: number, offset = 0) => Array.from({ length: count }, (_, i) => `${i + offset + 1}`); diff --git a/src/routes/mode/get.ts b/src/routes/mode/get.ts index ff02877..0be223d 100644 --- a/src/routes/mode/get.ts +++ b/src/routes/mode/get.ts @@ -1,10 +1,10 @@ -import { navBar, row } from "@routes/lib/components"; -import { withFlash } from "@routes/lib/flash"; -import { Handler } from "@routes/types"; import { RouteButtonBuilder } from "discord-embed-router"; import { ButtonStyle, EmbedBuilder } from "discord.js"; import { getUserMode } from "@db/users"; +import { navBar, row } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; import { MODES, PATH } from "./_shared"; diff --git a/src/routes/mode/post.ts b/src/routes/mode/post.ts index f39b291..ab8f331 100644 --- a/src/routes/mode/post.ts +++ b/src/routes/mode/post.ts @@ -1,8 +1,7 @@ +import { getUserMode, setUserMode } from "@db/users"; import { flashRedirect } from "@routes/lib/flash"; import { Handler } from "@routes/types"; -import { getUserMode, setUserMode } from "@db/users"; - import { isMode, MODES, PATH } from "./_shared"; export const modePost: Handler<"POST"> = async (router, interaction, state) => { diff --git a/src/routes/page-jump/modal.ts b/src/routes/page-jump/modal.ts index 7cfb79d..a3b5ceb 100644 --- a/src/routes/page-jump/modal.ts +++ b/src/routes/page-jump/modal.ts @@ -1,7 +1,8 @@ -import { Handler } from "@routes/types"; import { RouteModalBuilder } from "discord-embed-router"; import { LabelBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; +import { Handler } from "@routes/types"; + import { PAGE_JUMP } from "./_shared"; // opened by the "Page n of m" pagination button of any paged panel; the diff --git a/src/routes/recipients/[scope]/auto-ring/post.ts b/src/routes/recipients/[scope]/auto-ring/post.ts index b3b6ac2..438367b 100644 --- a/src/routes/recipients/[scope]/auto-ring/post.ts +++ b/src/routes/recipients/[scope]/auto-ring/post.ts @@ -1,9 +1,8 @@ +import { setAutoRing } from "@db/auto-ring"; import { flashRedirect } from "@routes/lib/flash"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; -import { setAutoRing } from "@db/auto-ring"; - import { panelPath, scopeSuffix } from "../../_shared"; export const recipientsAutoRingPost: Handler<"POST"> = async ( diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index f6b8dc6..4458f21 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -1,15 +1,3 @@ -import { - editSelectRow, - navBar, - pagedControls, - showOptionsOf, - subNav, -} from "@routes/lib/components"; -import { withFlash } from "@routes/lib/flash"; -import { commandMention } from "@routes/lib/mentions"; -import { pagedCountLine, paginate } from "@routes/lib/paging"; -import { channelIdOf, scopeOf } from "@routes/lib/scope"; -import { Handler } from "@routes/types"; import { RouteButtonBuilder, RouteChannelSelectMenuBuilder, @@ -24,6 +12,18 @@ import { import { getAutoRingSetting } from "@db/auto-ring"; import { getDefaultRingees } from "@db/default-ringees"; +import { + editSelectRow, + navBar, + pagedControls, + showOptionsOf, + subNav, +} from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; +import { pagedCountLine, paginate } from "@routes/lib/paging"; +import { channelIdOf, scopeOf } from "@routes/lib/scope"; +import { Handler } from "@routes/types"; import { panelPath } from "../_shared"; diff --git a/src/routes/recipients/[scope]/members/post.ts b/src/routes/recipients/[scope]/members/post.ts index fc3fe9c..3e90fdb 100644 --- a/src/routes/recipients/[scope]/members/post.ts +++ b/src/routes/recipients/[scope]/members/post.ts @@ -1,3 +1,9 @@ +import { + addDefaultRingee, + getDefaultRingees, + removeDefaultRingee, +} from "@db/default-ringees"; +import { joinWithAnd, mentionUser } from "@main/ring"; import { flashRedirect } from "@routes/lib/flash"; import { paginate, @@ -7,13 +13,6 @@ import { import { channelIdOf, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; -import { - addDefaultRingee, - getDefaultRingees, - removeDefaultRingee, -} from "@db/default-ringees"; -import { joinWithAnd, mentionUser } from "@main/ring"; - import { panelPath, scopeSuffix } from "../../_shared"; export const recipientsMembersPost: Handler<"POST"> = async ( diff --git a/src/routes/recipients/[scope]/reset/post.ts b/src/routes/recipients/[scope]/reset/post.ts index edcbbb4..316a1cf 100644 --- a/src/routes/recipients/[scope]/reset/post.ts +++ b/src/routes/recipients/[scope]/reset/post.ts @@ -1,10 +1,9 @@ +import { resetDefaultRingees } from "@db/default-ringees"; import { confirmed } from "@routes/lib/confirm"; import { flashRedirect } from "@routes/lib/flash"; import { channelIdOf, scopeOf } from "@routes/lib/scope"; import { Handler } from "@routes/types"; -import { resetDefaultRingees } from "@db/default-ringees"; - import { panelPath, scopeSuffix } from "../../_shared"; export const recipientsResetPost: Handler<"POST"> = async ( diff --git a/src/routes/ring/_shared.ts b/src/routes/ring/_shared.ts index 181dc7c..12f5b98 100644 --- a/src/routes/ring/_shared.ts +++ b/src/routes/ring/_shared.ts @@ -1,4 +1,3 @@ -import { flashRedirect } from "@routes/lib/flash"; import { Interaction, VoiceBasedChannel } from "discord.js"; import { @@ -8,6 +7,7 @@ import { ring, UserRingResult, } from "@main/ring"; +import { flashRedirect } from "@routes/lib/flash"; export const PANEL = "/ring"; export const NOT_IN_VC = diff --git a/src/routes/ring/default/post.ts b/src/routes/ring/default/post.ts index bad873a..e89e716 100644 --- a/src/routes/ring/default/post.ts +++ b/src/routes/ring/default/post.ts @@ -1,9 +1,8 @@ +import { getErrorMessage, ringDefaultUsers } from "@main/ring"; import { flashRedirect } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; import { Handler } from "@routes/types"; -import { getErrorMessage, ringDefaultUsers } from "@main/ring"; - import { noVoiceChannelFlash, PANEL, diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index 94c0667..69bea4d 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -1,7 +1,3 @@ -import { navBar, row, subNav } from "@routes/lib/components"; -import { withFlash } from "@routes/lib/flash"; -import { SELECT_MAX_VALUES } from "@routes/lib/paging"; -import { Handler } from "@routes/types"; import { RouteButtonBuilder, RouteUserSelectMenuBuilder, @@ -9,6 +5,10 @@ import { import { ActionRowBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; import { getAllDefaultRingees } from "@db/default-ringees"; +import { navBar, row, subNav } from "@routes/lib/components"; +import { withFlash } from "@routes/lib/flash"; +import { SELECT_MAX_VALUES } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; import { noVoiceChannelFlash, PANEL, voiceChannelOf } from "./_shared"; diff --git a/src/routes/signups/_shared.ts b/src/routes/signups/_shared.ts index 055b127..9f946c6 100644 --- a/src/routes/signups/_shared.ts +++ b/src/routes/signups/_shared.ts @@ -1,5 +1,3 @@ -import { backButton, homeButton, row } from "@routes/lib/components"; -import { Handler } from "@routes/types"; import { EmbedBuilder, Guild, @@ -11,6 +9,8 @@ import { getUserVoiceChatSignups, getVoiceChatRoleSignups, } from "@db/voice-chats"; +import { backButton, homeButton, row } from "@routes/lib/components"; +import { Handler } from "@routes/types"; export const COLOR = "#31a5a5"; export const PANEL = "/signups"; diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index 9888453..5d722ff 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -1,3 +1,14 @@ +import { + RouteButtonBuilder, + RouteChannelSelectMenuBuilder, +} from "discord-embed-router"; +import { + ActionRowBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, +} from "discord.js"; + import { navBar, pagedControls, @@ -12,16 +23,6 @@ import { SELECT_MAX_VALUES, } from "@routes/lib/paging"; import { Handler } from "@routes/types"; -import { - RouteButtonBuilder, - RouteChannelSelectMenuBuilder, -} from "discord-embed-router"; -import { - ActionRowBuilder, - ButtonStyle, - ChannelType, - EmbedBuilder, -} from "discord.js"; import { canManageRoleSignups, diff --git a/src/routes/signups/members/post.ts b/src/routes/signups/members/post.ts index 59eee31..c4c4edf 100644 --- a/src/routes/signups/members/post.ts +++ b/src/routes/signups/members/post.ts @@ -1,3 +1,5 @@ +import { addVoiceChatUser, removeVoiceChatUser } from "@db/voice-chats"; +import { joinWithAnd, mentionChannel } from "@main/ring"; import { flashRedirect } from "@routes/lib/flash"; import { paginate, @@ -6,9 +8,6 @@ import { } from "@routes/lib/paging"; import { Handler } from "@routes/types"; -import { addVoiceChatUser, removeVoiceChatUser } from "@db/voice-chats"; -import { joinWithAnd, mentionChannel } from "@main/ring"; - import { guildSignups, PANEL } from "../_shared"; // serves the panel's diff select and the /signup, /unsignup and /quit diff --git a/src/routes/signups/reset/post.ts b/src/routes/signups/reset/post.ts index 5d26e15..6a5ccd5 100644 --- a/src/routes/signups/reset/post.ts +++ b/src/routes/signups/reset/post.ts @@ -1,9 +1,8 @@ +import { removeVoiceChatUser } from "@db/voice-chats"; import { confirmed } from "@routes/lib/confirm"; import { flashRedirect } from "@routes/lib/flash"; import { Handler } from "@routes/types"; -import { removeVoiceChatUser } from "@db/voice-chats"; - import { guildSignups, PANEL } from "../_shared"; // clears the caller's signups in this guild only; other guilds keep theirs diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index 13c8feb..9bc18dd 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -1,7 +1,3 @@ -import { navBar, subNav } from "@routes/lib/components"; -import { flashRedirect, withFlash } from "@routes/lib/flash"; -import { paginate, withPageLabel } from "@routes/lib/paging"; -import { RingRouter } from "@routes/types"; import { RouteRedirect } from "discord-embed-router"; import { APIActionRowComponent, @@ -12,6 +8,10 @@ import { } from "discord.js"; import { joinWithAnd } from "@main/ring"; +import { navBar, subNav } from "@routes/lib/components"; +import { flashRedirect, withFlash } from "@routes/lib/flash"; +import { paginate, withPageLabel } from "@routes/lib/paging"; +import { RingRouter } from "@routes/types"; import { canManageRoleSignups, COLOR, PANEL, ROLES } from "../_shared"; diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index cae6a08..410f533 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -1,10 +1,3 @@ -import { - editSelectRow, - pagedControls, - showOptionsOf, -} from "@routes/lib/components"; -import { pagedCountLine, paginate } from "@routes/lib/paging"; -import { Handler } from "@routes/types"; import { RouteButtonBuilder, RouteChannelSelectMenuBuilder, @@ -14,6 +7,13 @@ import { ActionRowBuilder, ButtonStyle, ChannelType } from "discord.js"; import { getVoiceChatSignups } from "@db/voice-chats"; import { mentionChannel } from "@main/ring"; +import { + editSelectRow, + pagedControls, + showOptionsOf, +} from "@routes/lib/components"; +import { pagedCountLine, paginate } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; import { BY_CHANNEL, diff --git a/src/routes/signups/roles/by-channel/reset/post.ts b/src/routes/signups/roles/by-channel/reset/post.ts index d96e043..305a105 100644 --- a/src/routes/signups/roles/by-channel/reset/post.ts +++ b/src/routes/signups/roles/by-channel/reset/post.ts @@ -1,10 +1,9 @@ +import { getVoiceChatSignups, removeVoiceChatRole } from "@db/voice-chats"; +import { mentionChannel } from "@main/ring"; import { confirmed } from "@routes/lib/confirm"; import { flashRedirect } from "@routes/lib/flash"; import { Handler } from "@routes/types"; -import { getVoiceChatSignups, removeVoiceChatRole } from "@db/voice-chats"; -import { mentionChannel } from "@main/ring"; - import { BY_CHANNEL, roleEditGuard } from "../../_shared"; // clears every role pinged in one voice channel diff --git a/src/routes/signups/roles/by-channel/roles/post.ts b/src/routes/signups/roles/by-channel/roles/post.ts index 39ee3c4..e30af0a 100644 --- a/src/routes/signups/roles/by-channel/roles/post.ts +++ b/src/routes/signups/roles/by-channel/roles/post.ts @@ -1,12 +1,11 @@ -import { resolveSelectionEdit } from "@routes/lib/paging"; -import { Handler } from "@routes/types"; - import { addVoiceChatRole, getVoiceChatSignups, removeVoiceChatRole, } from "@db/voice-chats"; import { mentionRole } from "@main/ring"; +import { resolveSelectionEdit } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; import { BY_CHANNEL, @@ -31,7 +30,10 @@ export const rolesByChannelEditPost: Handler<"POST"> = async ( const { guild, scope } = guard; const query = state.queryParams; - const current = sortRoleIds(guild, (await getVoiceChatSignups(scope)).roleIds); + const current = sortRoleIds( + guild, + (await getVoiceChatSignups(scope)).roleIds, + ); const { addsRequested, removesRequested, alreadyPresent } = resolveSelectionEdit({ diff --git a/src/routes/signups/roles/by-role/channels/post.ts b/src/routes/signups/roles/by-role/channels/post.ts index 1481a96..80e3020 100644 --- a/src/routes/signups/roles/by-role/channels/post.ts +++ b/src/routes/signups/roles/by-role/channels/post.ts @@ -1,12 +1,11 @@ -import { resolveSelectionEdit } from "@routes/lib/paging"; -import { Handler } from "@routes/types"; - import { addVoiceChatRole, getVoiceChatRoleSignups, removeVoiceChatRole, } from "@db/voice-chats"; import { mentionChannel } from "@main/ring"; +import { resolveSelectionEdit } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; import { BY_ROLE, diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index 24d2e50..53c0a0a 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -1,10 +1,3 @@ -import { - editSelectRow, - pagedControls, - showOptionsOf, -} from "@routes/lib/components"; -import { pagedCountLine, paginate } from "@routes/lib/paging"; -import { Handler } from "@routes/types"; import { RouteButtonBuilder, RouteChannelSelectMenuBuilder, @@ -14,6 +7,13 @@ import { ActionRowBuilder, ButtonStyle, ChannelType } from "discord.js"; import { getVoiceChatRoleSignups } from "@db/voice-chats"; import { mentionRole } from "@main/ring"; +import { + editSelectRow, + pagedControls, + showOptionsOf, +} from "@routes/lib/components"; +import { pagedCountLine, paginate } from "@routes/lib/paging"; +import { Handler } from "@routes/types"; import { BY_ROLE, diff --git a/src/routes/signups/roles/by-role/reset/post.ts b/src/routes/signups/roles/by-role/reset/post.ts index cce2156..f032ade 100644 --- a/src/routes/signups/roles/by-role/reset/post.ts +++ b/src/routes/signups/roles/by-role/reset/post.ts @@ -1,10 +1,9 @@ +import { getVoiceChatRoleSignups, removeVoiceChatRole } from "@db/voice-chats"; +import { mentionRole } from "@main/ring"; import { confirmed } from "@routes/lib/confirm"; import { flashRedirect } from "@routes/lib/flash"; import { Handler } from "@routes/types"; -import { getVoiceChatRoleSignups, removeVoiceChatRole } from "@db/voice-chats"; -import { mentionRole } from "@main/ring"; - import { BY_ROLE, roleEditGuard } from "../../_shared"; import { guildVoiceChannelIds } from "../../../_shared"; diff --git a/src/routes/signups/roles/get.ts b/src/routes/signups/roles/get.ts index 2bf68ac..9895445 100644 --- a/src/routes/signups/roles/get.ts +++ b/src/routes/signups/roles/get.ts @@ -1,10 +1,11 @@ -import { Handler } from "@routes/types"; import { RouteChannelSelectMenuBuilder, RouteRoleSelectMenuBuilder, } from "discord-embed-router"; import { ActionRowBuilder, ChannelType } from "discord.js"; +import { Handler } from "@routes/types"; + import { canManageRoleSignups, guildOnlyRender, From d988c76a3f69d76fa599d268a83bdd84b75cc20d Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:46:06 -0400 Subject: [PATCH 63/82] update discord-embed-router to 1.3.0 Modal dispatches no longer accept reply flags; the confirm modal carries Ephemeral on its setTo instead, applied when a command-launched submission creates its reply. Co-Authored-By: Claude Fable 5 --- package-lock.json | 8 ++++---- package.json | 2 +- src/commands/unsignuprole.ts | 2 +- src/routes/lib/confirm.ts | 9 +++++++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab01b0d..a7a35c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "dependencies": { "@supabase/supabase-js": "^2.110.2", - "discord-embed-router": "^1.2.1", + "discord-embed-router": "^1.3.0", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", @@ -2946,9 +2946,9 @@ ] }, "node_modules/discord-embed-router": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.2.1.tgz", - "integrity": "sha512-SNs3TfuE4A03vFJSKZFaQRLIJ2JJ1lsC7vv0zp9+KnoWdYY7aObogLvpSaLvIu0p4GpPFQON9GccMCfwsAFKOQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.3.0.tgz", + "integrity": "sha512-3IDplfqGYjpqW5uZf+mumu07RZ5DpPWm/EevbUJNAvKb61AC0FYSDOD4VNqdTo25xoOWwiOCfQr0xtd4er8GMA==", "license": "MIT", "workspaces": [ "examples/basic-bot" diff --git a/package.json b/package.json index 23b1f67..16c6c3f 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@supabase/supabase-js": "^2.110.2", - "discord-embed-router": "^1.2.1", + "discord-embed-router": "^1.3.0", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", diff --git a/src/commands/unsignuprole.ts b/src/commands/unsignuprole.ts index 9f9e3e6..b6a1dfb 100644 --- a/src/commands/unsignuprole.ts +++ b/src/commands/unsignuprole.ts @@ -46,7 +46,7 @@ export const unsignuprole = { await router.dispatch( interaction, `/signups/roles/by-role/${role.id}/reset`, - { method: "MODAL", flags: [MessageFlags.Ephemeral] }, + { method: "MODAL" }, ); }, }; diff --git a/src/routes/lib/confirm.ts b/src/routes/lib/confirm.ts index ea82f0a..a4fb2c0 100644 --- a/src/routes/lib/confirm.ts +++ b/src/routes/lib/confirm.ts @@ -1,5 +1,10 @@ import { RouteModalBuilder } from "discord-embed-router"; -import { LabelBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; +import { + LabelBuilder, + MessageFlags, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; import { RingModal, RingRouter } from "@routes/types"; @@ -10,7 +15,7 @@ export const confirmModal = ( { to, title, word }: { to: string; title: string; word: string }, ): RingModal => new RouteModalBuilder(router) - .setTo(to, { method: "POST" }) + .setTo(to, { method: "POST", flags: [MessageFlags.Ephemeral] }) .setTitle(title) .addLabelComponents( new LabelBuilder() From b1b7e3ce20133e831fe4d2a6de6dc2dc457761d1 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:50:39 -0400 Subject: [PATCH 64/82] =?UTF-8?q?keep=20panel=20options=20behind=20the=20?= =?UTF-8?q?=E2=9A=99=20toggle=20on=20single-page=20lists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/routes/lib/components.ts | 71 +++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 34 deletions(-) diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 517bc5f..b2d85e3 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -160,10 +160,11 @@ export const subNav = ( // a paged panel's control row: the page controls with a switch into the // panel's option buttons (reset and friends), or the options with the way -// back, keyed by the `options` query param. A single-page list has no pager -// to share the row with, so its options show directly; a panel with no -// options and one page contributes no row, so the result is spread into a -// components array +// back, keyed by the `options` query param. Options always hide behind the +// toggle — destructive buttons like Reset stay one deliberate click away — +// so a single-page list shows just the toggle; a panel with no options and +// one page contributes no row, so the result is spread into a components +// array export const pagedControls = ( router: RingRouter, basePath: string, @@ -177,11 +178,37 @@ export const pagedControls = ( options: RingButton[]; }, ): APIActionRowComponent[] => { - if (pageCount <= 1) return options.length > 0 ? [row(...options)] : []; + const pageButton = (label: string, target: number, disabled: boolean) => + new RouteButtonBuilder(router) + .setLabel(label) + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled) + .setTo(basePath, { queryParams: { page: String(target) } }); + const pager = + pageCount <= 1 + ? [] + : [ + pageButton("◀ Prev", page - 1, page === 0), + // the middle button doubles as a page jump: it opens a modal asking + // which page to show, which redirects back to basePath + new RouteButtonBuilder(router) + .setLabel(`Page ${page + 1} of ${pageCount}`) + .setStyle(ButtonStyle.Secondary) + .setTo(PAGE_JUMP, { + method: "MODAL", + queryParams: { + to: basePath, + page: String(page), + pageCount: String(pageCount), + }, + }), + pageButton("Next ▶", page + 1, page === pageCount - 1), + ]; + if (options.length === 0) return pager.length > 0 ? [row(...pager)] : []; // the toggle leads the row in both modes — the leading slot is the only // position variable-width neighbors can't shift; open options lead with // the way back to the page controls - if (showOptions && options.length > 0) + if (showOptions) return [ row( new RouteButtonBuilder(router) @@ -191,39 +218,15 @@ export const pagedControls = ( ...options, ), ]; - const pageButton = (label: string, target: number, disabled: boolean) => - new RouteButtonBuilder(router) - .setLabel(label) - .setStyle(ButtonStyle.Secondary) - .setDisabled(disabled) - .setTo(basePath, { queryParams: { page: String(target) } }); return [ row( - ...(options.length > 0 - ? [ - new RouteButtonBuilder(router) - .setLabel("⚙ Options") - .setStyle(ButtonStyle.Secondary) - .setTo(basePath, { - queryParams: { page: String(page), options: "1" }, - }), - ] - : []), - pageButton("◀ Prev", page - 1, page === 0), - // the middle button doubles as a page jump: it opens a modal asking - // which page to show, which redirects back to basePath new RouteButtonBuilder(router) - .setLabel(`Page ${page + 1} of ${pageCount}`) + .setLabel("⚙ Options") .setStyle(ButtonStyle.Secondary) - .setTo(PAGE_JUMP, { - method: "MODAL", - queryParams: { - to: basePath, - page: String(page), - pageCount: String(pageCount), - }, + .setTo(basePath, { + queryParams: { page: String(page), options: "1" }, }), - pageButton("Next ▶", page + 1, page === pageCount - 1), + ...pager, ), ]; }; From 49d0d768bf1bd5b2995339b9afcd41e0c560a3c4 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:57:31 -0400 Subject: [PATCH 65/82] count only content pages in the paged count line pageCountOf appends a trailing empty page for adding entries when the last page fills the edit select; the count line derives its page count from the total instead so that navigation-only page never inflates it. Co-Authored-By: Claude Fable 5 --- src/routes/filter/[scope]/get.ts | 2 +- src/routes/lib/paging.test.ts | 12 ++++++++++++ src/routes/lib/paging.ts | 13 ++++++------- src/routes/recipients/[scope]/get.ts | 2 +- src/routes/signups/get.ts | 2 +- src/routes/signups/roles/by-channel/get.ts | 2 +- src/routes/signups/roles/by-role/get.ts | 2 +- 7 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index e7dfe0c..567eb60 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -43,7 +43,7 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { (type === "whitelist" ? "Only the people listed below can ring you, and you only ring them." : "The people listed below can't ring you, and you won't ring them.") + - `\n\n${pagedCountLine("Members", entries.length, pageCount)}`, + `\n\n${pagedCountLine("Members", entries.length)}`, ); const scopeSelect = new ActionRowBuilder() diff --git a/src/routes/lib/paging.test.ts b/src/routes/lib/paging.test.ts index f7badca..61cb86d 100644 --- a/src/routes/lib/paging.test.ts +++ b/src/routes/lib/paging.test.ts @@ -3,6 +3,7 @@ import { expect, test } from "vitest"; import { diffSelection, PAGE_SIZE, + pagedCountLine, paginate, withPageLabel, } from "@routes/lib/paging"; @@ -44,6 +45,17 @@ test("a list that is an exact multiple of the page size adds no trailing page", }); }); +test("the count line counts only pages that hold entries", () => { + expect(pagedCountLine("Signups", 0)).toBe("**Signups** · None"); + expect(pagedCountLine("Signups", PAGE_SIZE)).toBe(`**Signups** · ${PAGE_SIZE}`); + expect(pagedCountLine("Signups", PAGE_SIZE + 1)).toBe( + `**Signups** · ${PAGE_SIZE + 1} across 2 pages`, + ); + expect(pagedCountLine("Signups", PAGE_SIZE * 2)).toBe( + `**Signups** · ${PAGE_SIZE * 2} across 2 pages`, + ); +}); + test("a stale page index clamps to the last page", () => { const items = ids(PAGE_SIZE + 5); expect(paginate(items, "7").page).toBe(1); diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts index 0fbc00d..58b6f53 100644 --- a/src/routes/lib/paging.ts +++ b/src/routes/lib/paging.ts @@ -13,13 +13,12 @@ export type Page = { // the "**Label** · 23 across 3 pages" line paged panels show instead of // repeating the entries the select below already displays as its selection; -// the pager button carries which page is being viewed -export const pagedCountLine = ( - label: string, - total: number, - pageCount: number, -): string => - `**${label}** · ${total > 0 ? total : "None"}${pageCount > 1 ? ` across ${pageCount} pages` : ""}`; +// the pager button carries which page is being viewed. Counts only pages +// that hold entries, so pageCountOf's trailing add page never inflates it +export const pagedCountLine = (label: string, total: number): string => { + const contentPages = Math.ceil(total / PAGE_SIZE); + return `**${label}** · ${total > 0 ? total : "None"}${contentPages > 1 ? ` across ${contentPages} pages` : ""}`; +}; // setPattern options for a paged edit select's POST. The per-render key // gives every render a fresh customId: the Discord client keeps a select's diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 4458f21..a620ae1 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -76,7 +76,7 @@ export const recipientsGet: Handler<"GET"> = async ( state.queryParams, `These people get rung when you use ${commandMention(state.globals, "ring_defaults")}${channelId ? ` in <#${channelId}>` : ""}, or on every voice channel join if auto-ring is on.\n\n` + `**Auto-ring** · ${autoRingValue}\n` + - pagedCountLine("Ringees", sorted.length, pageCount), + pagedCountLine("Ringees", sorted.length), ); const scopeSelect = new ActionRowBuilder() diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index 5d722ff..2932a75 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -50,7 +50,7 @@ export const signupsGet: Handler<"GET"> = async ( const description = withFlash( state.queryParams, "You get rung when someone starts a call in one of your signed-up channels. Edit them below.\n\n" + - pagedCountLine("Signups", signups.length, pageCount), + pagedCountLine("Signups", signups.length), ); return { diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index 410f533..5647b7d 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -80,7 +80,7 @@ export const rolesByChannelGet: Handler<"GET"> = async ( queryParams: state.queryParams, body: `${LEAD}\n\n**Viewing** ${mentionChannel(scope)}\n` + - pagedCountLine("Roles pinged here", roleIds.length, pageCount), + pagedCountLine("Roles pinged here", roleIds.length), // the scope select leads as the page's context, then its edit list and // pager; the switch and section bar follow in the frame rows: [ diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index 53c0a0a..98a3110 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -84,7 +84,7 @@ export const rolesByRoleGet: Handler<"GET"> = async ( queryParams: state.queryParams, body: `${LEAD}\n\n**Viewing** ${mentionRole(scope)}\n` + - pagedCountLine("Signed up for", channelIds.length, pageCount), + pagedCountLine("Signed up for", channelIds.length), // the scope select leads as the page's context, then its edit list and // pager; the switch and section bar follow in the frame rows: [ From f238cdc65337cecc2228dbe9d0bbed8e4a3fd4c2 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:12:15 -0400 Subject: [PATCH 66/82] use noun count-line labels and point the scope prompt below Co-Authored-By: Claude Fable 5 --- src/routes/signups/roles/by-channel/get.ts | 2 +- src/routes/signups/roles/by-role/get.ts | 2 +- src/routes/signups/roles/get.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index 5647b7d..3dbcfed 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -80,7 +80,7 @@ export const rolesByChannelGet: Handler<"GET"> = async ( queryParams: state.queryParams, body: `${LEAD}\n\n**Viewing** ${mentionChannel(scope)}\n` + - pagedCountLine("Roles pinged here", roleIds.length), + pagedCountLine("Roles", roleIds.length), // the scope select leads as the page's context, then its edit list and // pager; the switch and section bar follow in the frame rows: [ diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index 98a3110..fe1d2e6 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -84,7 +84,7 @@ export const rolesByRoleGet: Handler<"GET"> = async ( queryParams: state.queryParams, body: `${LEAD}\n\n**Viewing** ${mentionRole(scope)}\n` + - pagedCountLine("Signed up for", channelIds.length), + pagedCountLine("Signups", channelIds.length), // the scope select leads as the page's context, then its edit list and // pager; the switch and section bar follow in the frame rows: [ diff --git a/src/routes/signups/roles/get.ts b/src/routes/signups/roles/get.ts index 9895445..ec22df1 100644 --- a/src/routes/signups/roles/get.ts +++ b/src/routes/signups/roles/get.ts @@ -45,7 +45,7 @@ export const rolesGet: Handler<"GET"> = async (router, interaction, state) => { router, interaction, queryParams: state.queryParams, - body: `${LEAD}\n\nPick a voice channel or a role above to view and edit its signups.`, + body: `${LEAD}\n\nPick a voice channel or a role below to view and edit its signups.`, rows: [channelSelectRow, roleSelectRow], }); }; From 8cb994cecbfc8c9732fbc29dd3d9941f7d7d204c Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:28:36 -0400 Subject: [PATCH 67/82] say what carried-over entries mean on a filter type switch Switching a filter's type keeps its entries and reverses what they mean: a blacklist's blocked users become the only people a whitelist lets through. The notice now spells that out and warns instead of confirming when there are entries to reinterpret. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/filter/[scope]/type/post.ts | 14 +++++- src/routes/filter/filter.test.ts | 59 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/routes/filter/[scope]/type/post.ts b/src/routes/filter/[scope]/type/post.ts index 6c1b40d..d7a9044 100644 --- a/src/routes/filter/[scope]/type/post.ts +++ b/src/routes/filter/[scope]/type/post.ts @@ -25,9 +25,19 @@ export const filterTypePost: Handler<"POST"> = async ( ); await setFilterType(interaction.user.id, channelId, to === "whitelist"); + + // the entries carry over and the switch reverses what they mean, so the + // notice has to say so: a blacklist's blocked users become the only people + // a whitelist lets through, and vice versa + const lead = `${scopeName(scope, "filter", { capitalize: true })} is now a ${to}`; + if ((filter?.entries.size ?? 0) === 0) + return flashRedirect(panel, lead, "success"); + return flashRedirect( panel, - `${scopeName(scope, "filter", { capitalize: true })} is now a ${to}`, - "success", + to === "whitelist" + ? `${lead}. The people on it were blocked, and are now the only people who can ring you` + : `${lead}. The people on it were the only people who could ring you, and are now blocked`, + "warn", ); }; diff --git a/src/routes/filter/filter.test.ts b/src/routes/filter/filter.test.ts index 408a4d8..eac0ed8 100644 --- a/src/routes/filter/filter.test.ts +++ b/src/routes/filter/filter.test.ts @@ -6,10 +6,12 @@ import { getFilter, removeFilterEntry, resetFilter, + setFilterType, } from "@db/filters"; import { filterMembersPost } from "./[scope]/members/post"; import { filterResetPost } from "./[scope]/reset/post"; +import { filterTypePost } from "./[scope]/type/post"; vi.mock("@db/filters", async (importOriginal) => ({ ...(await importOriginal()), @@ -39,6 +41,14 @@ const membersPost = (query: string, values?: string[]) => membersState(query, values), ); +const typePost = (query: string) => + filterTypePost(undefined as never, interaction, { + params: { scope: "global" }, + path: "/filter/global/type", + queryParams: new URLSearchParams(query), + timestamp: 0, + } as unknown as Parameters[2]); + const resetPost = (confirmation: string) => filterResetPost(undefined as never, interaction, { params: { scope: "global" }, @@ -113,6 +123,55 @@ test("a filter reset without matching confirmation text mutates nothing", async expect(flashParams.get("flash")).toContain("did not match"); }); +// entries carry over and reverse meaning on a type switch, so a filter that +// has any warns instead of confirming +test("switching a blacklist that has entries sets the type and warns", async () => { + vi.mocked(getFilter).mockResolvedValue({ + isWhitelist: false, + entries: new Set(["1", "2"]), + }); + + const result = await typePost("to=whitelist"); + + expect(setFilterType).toHaveBeenCalledExactlyOnceWith("caller", null, true); + expect( + new URLSearchParams(result.queryParams as Record).get( + "level", + ), + ).toBe("warn"); +}); + +test("switching a whitelist that has entries sets the type and warns", async () => { + vi.mocked(getFilter).mockResolvedValue({ + isWhitelist: true, + entries: new Set(["1"]), + }); + + const result = await typePost("to=blacklist"); + + expect(setFilterType).toHaveBeenCalledExactlyOnceWith("caller", null, false); + expect( + new URLSearchParams(result.queryParams as Record).get( + "level", + ), + ).toBe("warn"); +}); + +test("switching a filter with no entries confirms without warning", async () => { + vi.mocked(getFilter).mockResolvedValue({ + isWhitelist: false, + entries: new Set(), + }); + + const result = await typePost("to=whitelist"); + + expect( + new URLSearchParams(result.queryParams as Record).get( + "level", + ), + ).toBe("success"); +}); + test("blocking an already blocked user reports it without a duplicate write", async () => { vi.mocked(getFilter).mockResolvedValue({ isWhitelist: false, From 1bd2c91d67483999e5361f40e702f121271fdac3 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:57:30 -0400 Subject: [PATCH 68/82] label the section bar and move Ring defaults to its panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section bar showed the active section as a selected value, reading like a value picker. Make the active section the placeholder ("Section: …") instead, so the bar reads as a location label; selecting any option routes there. Move the Ring defaults action from the Quick ring panel onto the Default ringees panel it rings, left of ⚙ Options via a new pagedControls leading slot, and redirect its POST back to that panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/lib/components.ts | 35 +++++++++++++-------- src/routes/recipients/[scope]/get.ts | 13 ++++++++ src/routes/ring/default/post.ts | 23 ++++++++------ src/routes/ring/get.ts | 15 ++------- src/routes/ring/ring.test.ts | 46 +++++++++++++++++++++++++++- 5 files changed, 97 insertions(+), 35 deletions(-) diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index b2d85e3..728cde5 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -98,10 +98,11 @@ const SECTIONS: readonly Tab[] = [ ]; // the persistent section bar every panel ends on, as a string select so all -// sections fit one row. The active section is the menu's default option, so it -// shows as the current value; reselecting it just reloads that section. The -// Signups option carries the branded voice-chat emoji when the bot can use it, -// else a unicode bell +// sections fit one row. The active section names the placeholder ("Section: …") +// rather than being a selected option, so the bar reads as a location label +// instead of a value picker; selecting any section routes there. The Signups +// option carries the branded voice-chat emoji when the bot can use it, else a +// unicode bell export const navBar = ( router: RingRouter, interaction: Interaction, @@ -119,18 +120,20 @@ export const navBar = ( const option = ({ section, label, path }: Tab) => { const target = section === "ringees" && inVoice ? "/ring" : path; - const builder = new RouteStringSelectMenuOptionBuilder(router) - .setTo(target) - .setDefault(section === active); + const builder = new RouteStringSelectMenuOptionBuilder(router).setTo(target); if (section === "signups" && vc) builder.setLabel("Signups").setEmoji(vc); else builder.setLabel(label); return builder; }; + const activeLabel = active + ? SECTIONS.find((tab) => tab.section === active)?.label + : undefined; + // each option carries its own encoded target, and the builder's default // pattern routes the submission to the selected one const select = new RouteStringSelectMenuBuilder(router) - .setPlaceholder("Jump to a section") + .setPlaceholder(activeLabel ? `Section: ${activeLabel}` : "Jump to a section") .setTos(SECTIONS.map(option)); return new ActionRowBuilder() @@ -173,9 +176,13 @@ export const pagedControls = ( pageCount, showOptions, options, + leading = [], }: Pick & { showOptions: boolean; options: RingButton[]; + // actions that stay visible left of the ⚙ Options toggle in both modes, + // for a panel-level action that isn't a setting (e.g. Ring defaults) + leading?: RingButton[]; }, ): APIActionRowComponent[] => { const pageButton = (label: string, target: number, disabled: boolean) => @@ -204,10 +211,12 @@ export const pagedControls = ( }), pageButton("Next ▶", page + 1, page === pageCount - 1), ]; - if (options.length === 0) return pager.length > 0 ? [row(...pager)] : []; - // the toggle leads the row in both modes — the leading slot is the only - // position variable-width neighbors can't shift; open options lead with - // the way back to the page controls + if (options.length === 0) + return leading.length > 0 || pager.length > 0 + ? [row(...leading, ...pager)] + : []; + // the ⚙ toggle / ◀ Back button anchors a fixed slot so variable-width pager + // neighbors can't shift it; any leading actions sit just inside it if (showOptions) return [ row( @@ -215,11 +224,13 @@ export const pagedControls = ( .setLabel("◀ Back") .setStyle(ButtonStyle.Secondary) .setTo(basePath, { queryParams: { page: String(page) } }), + ...leading, ...options, ), ]; return [ row( + ...leading, new RouteButtonBuilder(router) .setLabel("⚙ Options") .setStyle(ButtonStyle.Secondary) diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index a620ae1..cd99be7 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -105,6 +105,18 @@ export const recipientsGet: Handler<"GET"> = async ( }, ); + // ringing the saved defaults belongs with the list it rings; only global + // scope has a "defaults" set to ring, and the POST redirects back here + const ringDefaultsAction = + scope === "global" + ? [ + new RouteButtonBuilder(router) + .setLabel("Ring defaults") + .setStyle(ButtonStyle.Success) + .setTo("/ring/default", { method: "POST" }), + ] + : []; + const ringOptions = [ new RouteButtonBuilder(router) .setLabel(autoRing.effective ? "Disable auto-ring" : "Enable auto-ring") @@ -141,6 +153,7 @@ export const recipientsGet: Handler<"GET"> = async ( pageCount, showOptions: showOptionsOf(state.queryParams), options: ringOptions, + leading: ringDefaultsAction, }), subNav(router, [ { label: "Quick ring", path: "/ring" }, diff --git a/src/routes/ring/default/post.ts b/src/routes/ring/default/post.ts index e89e716..2baf238 100644 --- a/src/routes/ring/default/post.ts +++ b/src/routes/ring/default/post.ts @@ -3,12 +3,11 @@ import { flashRedirect } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; import { Handler } from "@routes/types"; -import { - noVoiceChannelFlash, - PANEL, - ringResultsFlash, - voiceChannelOf, -} from "../_shared"; +import { noVoiceChannelFlash, ringResultsFlash, voiceChannelOf } from "../_shared"; + +// the Ring defaults button lives on the Default ringees panel, so results and +// warnings redirect back to that panel +const DEFAULT_RINGEES_PANEL = "/recipients/global"; export const ringDefaultPost: Handler<"POST"> = async ( router, @@ -17,7 +16,11 @@ export const ringDefaultPost: Handler<"POST"> = async ( ) => { const channel = voiceChannelOf(interaction); if (!channel) - return flashRedirect(PANEL, noVoiceChannelFlash(interaction), "warn"); + return flashRedirect( + DEFAULT_RINGEES_PANEL, + noVoiceChannelFlash(interaction), + "warn", + ); try { const results = await ringDefaultUsers( @@ -26,17 +29,17 @@ export const ringDefaultPost: Handler<"POST"> = async ( "wants you to join", ); const { flash, level } = ringResultsFlash(results); - return flashRedirect(PANEL, flash, level); + return flashRedirect(DEFAULT_RINGEES_PANEL, flash, level); } catch (err) { const message = getErrorMessage(err); return message === "no default users to ring" ? flashRedirect( - PANEL, + DEFAULT_RINGEES_PANEL, `You have no default ring recipients. Use the Ring recipients panel on the home page or ${commandMention(state.globals, "default_ring_recipients")} to add some`, "warn", ) : flashRedirect( - PANEL, + DEFAULT_RINGEES_PANEL, `Can't ring your default recipients because ${message}`, "warn", ); diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index 69bea4d..4e54ebe 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -1,11 +1,8 @@ -import { - RouteButtonBuilder, - RouteUserSelectMenuBuilder, -} from "discord-embed-router"; -import { ActionRowBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; +import { RouteUserSelectMenuBuilder } from "discord-embed-router"; +import { ActionRowBuilder, EmbedBuilder } from "discord.js"; import { getAllDefaultRingees } from "@db/default-ringees"; -import { navBar, row, subNav } from "@routes/lib/components"; +import { navBar, subNav } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { SELECT_MAX_VALUES } from "@routes/lib/paging"; import { Handler } from "@routes/types"; @@ -60,12 +57,6 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { .setPattern(`${PANEL}/users`, { method: "POST" }), ) .toJSON(), - row( - new RouteButtonBuilder(router) - .setLabel("Ring defaults") - .setStyle(ButtonStyle.Success) - .setTo(`${PANEL}/default`, { method: "POST" }), - ), ringViews, navBar(router, interaction, { active: "ringees" }), ], diff --git a/src/routes/ring/ring.test.ts b/src/routes/ring/ring.test.ts index 6379337..3a181e7 100644 --- a/src/routes/ring/ring.test.ts +++ b/src/routes/ring/ring.test.ts @@ -1,8 +1,9 @@ import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; -import { ring } from "@main/ring"; +import { ring, ringDefaultUsers } from "@main/ring"; +import { ringDefaultPost } from "./default/post"; import { ringUsersPost } from "./users/post"; vi.mock("@main/ring", async (importOriginal) => ({ @@ -32,10 +33,53 @@ const state = (query: string, values?: string[]) => values, }) as unknown as Parameters[2]; +const defaultState = () => + ({ + params: {}, + path: "/ring/default", + queryParams: new URLSearchParams(), + timestamp: 0, + globals: { commandIds: new Map() }, + }) as unknown as Parameters[2]; + beforeEach(() => { vi.clearAllMocks(); }); +test("ringing defaults rings the saved list and returns to the default ringees panel", async () => { + vi.mocked(ringDefaultUsers).mockResolvedValue([ + { userId: "9", status: "fulfilled" }, + ]); + + const result = await ringDefaultPost( + undefined as never, + makeInteraction(true), + defaultState(), + ); + + expect(ringDefaultUsers).toHaveBeenCalledExactlyOnceWith( + voiceChannel, + "caller", + "wants you to join", + ); + expect(result.redirect).toBe("/recipients/global"); +}); + +test("ringing defaults while not in a voice channel warns without ringing", async () => { + const result = await ringDefaultPost( + undefined as never, + makeInteraction(false), + defaultState(), + ); + + expect(ringDefaultUsers).not.toHaveBeenCalled(); + expect(result.redirect).toBe("/recipients/global"); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); +}); + test("ringing while not in a voice channel flashes the join hint and rings nobody", async () => { const result = await ringUsersPost( undefined as never, From dcaefe7e5840121ec4102d6e200931af076b817e Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:09:35 -0400 Subject: [PATCH 69/82] label sub-nav views and keep Ring defaults on Quick ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section bar's "Section:" placeholder now has a sibling: the active sub-nav button is prefixed "View:" to name the current view, so the two nav levels announce themselves the same way and the sub-view switch reads as navigation rather than a pair of actions. That labeling also distinguishes the Default ringees *view* from the Ring defaults *action*, which is what made them feel jumbled — so Ring defaults stays on the Quick ring panel where a ring-now action belongs, and pagedControls drops the leading-action slot it no longer needs. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/lib/components.ts | 24 +++++++++--------------- src/routes/recipients/[scope]/get.ts | 13 ------------- src/routes/ring/default/post.ts | 23 ++++++++++------------- src/routes/ring/get.ts | 15 ++++++++++++--- src/routes/ring/ring.test.ts | 6 +++--- 5 files changed, 34 insertions(+), 47 deletions(-) diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 728cde5..9f16e8d 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -142,8 +142,10 @@ export const navBar = ( }; // the sub-view switch a section shows just above the bar when it has sibling -// views (e.g. My signups / Role signups, Ring now / Default ringees). The -// current view is an inert Primary; the others are Secondary links +// views (e.g. My signups / Role signups, Quick ring / Default ringees). The +// current view is an inert Primary prefixed "View:" to name where you are, +// mirroring the section bar's "Section:" placeholder; the others are Secondary +// links export const subNav = ( router: RingRouter, items: { label: string; path: string; active?: boolean }[], @@ -151,7 +153,7 @@ export const subNav = ( row( ...items.map((item) => new RouteButtonBuilder(router) - .setLabel(item.label) + .setLabel(item.active ? `View: ${item.label}` : item.label) .setStyle(item.active ? ButtonStyle.Primary : ButtonStyle.Secondary) .setDisabled(item.active ?? false) // a sub-nav item can point at the same path as the bar's active tab @@ -176,13 +178,9 @@ export const pagedControls = ( pageCount, showOptions, options, - leading = [], }: Pick & { showOptions: boolean; options: RingButton[]; - // actions that stay visible left of the ⚙ Options toggle in both modes, - // for a panel-level action that isn't a setting (e.g. Ring defaults) - leading?: RingButton[]; }, ): APIActionRowComponent[] => { const pageButton = (label: string, target: number, disabled: boolean) => @@ -211,12 +209,10 @@ export const pagedControls = ( }), pageButton("Next ▶", page + 1, page === pageCount - 1), ]; - if (options.length === 0) - return leading.length > 0 || pager.length > 0 - ? [row(...leading, ...pager)] - : []; - // the ⚙ toggle / ◀ Back button anchors a fixed slot so variable-width pager - // neighbors can't shift it; any leading actions sit just inside it + if (options.length === 0) return pager.length > 0 ? [row(...pager)] : []; + // the toggle leads the row in both modes — the leading slot is the only + // position variable-width neighbors can't shift; open options lead with + // the way back to the page controls if (showOptions) return [ row( @@ -224,13 +220,11 @@ export const pagedControls = ( .setLabel("◀ Back") .setStyle(ButtonStyle.Secondary) .setTo(basePath, { queryParams: { page: String(page) } }), - ...leading, ...options, ), ]; return [ row( - ...leading, new RouteButtonBuilder(router) .setLabel("⚙ Options") .setStyle(ButtonStyle.Secondary) diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index cd99be7..a620ae1 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -105,18 +105,6 @@ export const recipientsGet: Handler<"GET"> = async ( }, ); - // ringing the saved defaults belongs with the list it rings; only global - // scope has a "defaults" set to ring, and the POST redirects back here - const ringDefaultsAction = - scope === "global" - ? [ - new RouteButtonBuilder(router) - .setLabel("Ring defaults") - .setStyle(ButtonStyle.Success) - .setTo("/ring/default", { method: "POST" }), - ] - : []; - const ringOptions = [ new RouteButtonBuilder(router) .setLabel(autoRing.effective ? "Disable auto-ring" : "Enable auto-ring") @@ -153,7 +141,6 @@ export const recipientsGet: Handler<"GET"> = async ( pageCount, showOptions: showOptionsOf(state.queryParams), options: ringOptions, - leading: ringDefaultsAction, }), subNav(router, [ { label: "Quick ring", path: "/ring" }, diff --git a/src/routes/ring/default/post.ts b/src/routes/ring/default/post.ts index 2baf238..e89e716 100644 --- a/src/routes/ring/default/post.ts +++ b/src/routes/ring/default/post.ts @@ -3,11 +3,12 @@ import { flashRedirect } from "@routes/lib/flash"; import { commandMention } from "@routes/lib/mentions"; import { Handler } from "@routes/types"; -import { noVoiceChannelFlash, ringResultsFlash, voiceChannelOf } from "../_shared"; - -// the Ring defaults button lives on the Default ringees panel, so results and -// warnings redirect back to that panel -const DEFAULT_RINGEES_PANEL = "/recipients/global"; +import { + noVoiceChannelFlash, + PANEL, + ringResultsFlash, + voiceChannelOf, +} from "../_shared"; export const ringDefaultPost: Handler<"POST"> = async ( router, @@ -16,11 +17,7 @@ export const ringDefaultPost: Handler<"POST"> = async ( ) => { const channel = voiceChannelOf(interaction); if (!channel) - return flashRedirect( - DEFAULT_RINGEES_PANEL, - noVoiceChannelFlash(interaction), - "warn", - ); + return flashRedirect(PANEL, noVoiceChannelFlash(interaction), "warn"); try { const results = await ringDefaultUsers( @@ -29,17 +26,17 @@ export const ringDefaultPost: Handler<"POST"> = async ( "wants you to join", ); const { flash, level } = ringResultsFlash(results); - return flashRedirect(DEFAULT_RINGEES_PANEL, flash, level); + return flashRedirect(PANEL, flash, level); } catch (err) { const message = getErrorMessage(err); return message === "no default users to ring" ? flashRedirect( - DEFAULT_RINGEES_PANEL, + PANEL, `You have no default ring recipients. Use the Ring recipients panel on the home page or ${commandMention(state.globals, "default_ring_recipients")} to add some`, "warn", ) : flashRedirect( - DEFAULT_RINGEES_PANEL, + PANEL, `Can't ring your default recipients because ${message}`, "warn", ); diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index 4e54ebe..69bea4d 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -1,8 +1,11 @@ -import { RouteUserSelectMenuBuilder } from "discord-embed-router"; -import { ActionRowBuilder, EmbedBuilder } from "discord.js"; +import { + RouteButtonBuilder, + RouteUserSelectMenuBuilder, +} from "discord-embed-router"; +import { ActionRowBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; import { getAllDefaultRingees } from "@db/default-ringees"; -import { navBar, subNav } from "@routes/lib/components"; +import { navBar, row, subNav } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { SELECT_MAX_VALUES } from "@routes/lib/paging"; import { Handler } from "@routes/types"; @@ -57,6 +60,12 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { .setPattern(`${PANEL}/users`, { method: "POST" }), ) .toJSON(), + row( + new RouteButtonBuilder(router) + .setLabel("Ring defaults") + .setStyle(ButtonStyle.Success) + .setTo(`${PANEL}/default`, { method: "POST" }), + ), ringViews, navBar(router, interaction, { active: "ringees" }), ], diff --git a/src/routes/ring/ring.test.ts b/src/routes/ring/ring.test.ts index 3a181e7..b5c388b 100644 --- a/src/routes/ring/ring.test.ts +++ b/src/routes/ring/ring.test.ts @@ -46,7 +46,7 @@ beforeEach(() => { vi.clearAllMocks(); }); -test("ringing defaults rings the saved list and returns to the default ringees panel", async () => { +test("ringing defaults rings the saved list and returns to the Quick ring panel", async () => { vi.mocked(ringDefaultUsers).mockResolvedValue([ { userId: "9", status: "fulfilled" }, ]); @@ -62,7 +62,7 @@ test("ringing defaults rings the saved list and returns to the default ringees p "caller", "wants you to join", ); - expect(result.redirect).toBe("/recipients/global"); + expect(result.redirect).toBe("/ring"); }); test("ringing defaults while not in a voice channel warns without ringing", async () => { @@ -73,7 +73,7 @@ test("ringing defaults while not in a voice channel warns without ringing", asyn ); expect(ringDefaultUsers).not.toHaveBeenCalled(); - expect(result.redirect).toBe("/recipients/global"); + expect(result.redirect).toBe("/ring"); const flashParams = new URLSearchParams( result.queryParams as Record, ); From 94065caea574cebe007b93504d672eecb940947b Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:12:01 -0400 Subject: [PATCH 70/82] prefix the active sub-nav view with "Viewing:" Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/lib/components.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 9f16e8d..0b14ed7 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -143,7 +143,7 @@ export const navBar = ( // the sub-view switch a section shows just above the bar when it has sibling // views (e.g. My signups / Role signups, Quick ring / Default ringees). The -// current view is an inert Primary prefixed "View:" to name where you are, +// current view is an inert Primary prefixed "Viewing:" to name where you are, // mirroring the section bar's "Section:" placeholder; the others are Secondary // links export const subNav = ( @@ -153,7 +153,7 @@ export const subNav = ( row( ...items.map((item) => new RouteButtonBuilder(router) - .setLabel(item.active ? `View: ${item.label}` : item.label) + .setLabel(item.active ? `Viewing: ${item.label}` : item.label) .setStyle(item.active ? ButtonStyle.Primary : ButtonStyle.Secondary) .setDisabled(item.active ?? false) // a sub-nav item can point at the same path as the bar's active tab From 507337d88db0b1c90a004187ae2cc57e9b2cb767 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:15:51 -0400 Subject: [PATCH 71/82] spell out global defaults ring in a channel too Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/recipients/[scope]/get.ts | 9 +++++++-- src/routes/ring/get.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index a620ae1..308a261 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -72,11 +72,16 @@ export const recipientsGet: Handler<"GET"> = async ( ? `${autoRing.override ? "On" : "Off"} for <#${channelId}> (global: ${autoRing.global ? "on" : "off"})` : `${autoRing.effective ? "On" : "Off"} (from your global setting)`; + // channel ringees ring on top of the global defaults, so the channel scope + // says so; global scope's "these people" already is everyone + const ringsClause = channelId + ? `These people get rung — along with your global default ringees — when you use ${commandMention(state.globals, "ring_defaults")} in <#${channelId}>` + : `These people get rung when you use ${commandMention(state.globals, "ring_defaults")}`; const description = withFlash( state.queryParams, - `These people get rung when you use ${commandMention(state.globals, "ring_defaults")}${channelId ? ` in <#${channelId}>` : ""}, or on every voice channel join if auto-ring is on.\n\n` + + `${ringsClause}, or on every voice channel join if auto-ring is on.\n\n` + `**Auto-ring** · ${autoRingValue}\n` + - pagedCountLine("Ringees", sorted.length), + pagedCountLine(channelId ? "Channel ringees" : "Ringees", sorted.length), ); const scopeSelect = new ActionRowBuilder() diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index 69bea4d..103750e 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -40,7 +40,7 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { const description = withFlash( state.queryParams, `Ringing people in <#${channel.id}>.\n\n` + - `**Your defaults** · ${defaults.length > 0 ? defaults.length : "None"}`, + `**Default ringees** · ${defaults.length > 0 ? defaults.length : "None"}`, ); return { From 6d030e63a3626482adacf26c5e8f7f0e4aa8a5ca Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:17:55 -0400 Subject: [PATCH 72/82] put the global-defaults note in parentheses Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/recipients/[scope]/get.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 308a261..0643f25 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -75,7 +75,7 @@ export const recipientsGet: Handler<"GET"> = async ( // channel ringees ring on top of the global defaults, so the channel scope // says so; global scope's "these people" already is everyone const ringsClause = channelId - ? `These people get rung — along with your global default ringees — when you use ${commandMention(state.globals, "ring_defaults")} in <#${channelId}>` + ? `These people (along with your global default ringees) get rung when you use ${commandMention(state.globals, "ring_defaults")} in <#${channelId}>` : `These people get rung when you use ${commandMention(state.globals, "ring_defaults")}`; const description = withFlash( state.queryParams, From 33d3d089a245fadcacc6cab9e178e6d31bbe5da1 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:11:33 -0400 Subject: [PATCH 73/82] drop "Your" from panel titles Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/filter/[scope]/get.ts | 2 +- src/routes/mode/get.ts | 2 +- src/routes/signups/get.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index 567eb60..84e57d7 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -93,7 +93,7 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { new EmbedBuilder() .setColor(COLOR) .setTitle( - scope === "global" ? "🛡️ Your global filter" : "🛡️ Channel filter", + scope === "global" ? "🛡️ Global filter" : "🛡️ Channel filter", ) .setDescription(description), ], diff --git a/src/routes/mode/get.ts b/src/routes/mode/get.ts index 0be223d..6540798 100644 --- a/src/routes/mode/get.ts +++ b/src/routes/mode/get.ts @@ -23,7 +23,7 @@ export const modeGet: Handler<"GET"> = async (router, interaction, state) => { embeds: [ new EmbedBuilder() .setColor(COLOR) - .setTitle("💤 Your mode") + .setTitle("💤 Mode") .setDescription( withFlash( state.queryParams, diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index 2932a75..c9cfab0 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -57,7 +57,7 @@ export const signupsGet: Handler<"GET"> = async ( embeds: [ new EmbedBuilder() .setColor(COLOR) - .setTitle("🔔 Your signups") + .setTitle("🔔 Signups") .setDescription(description), ], components: [ From e3638f08315598a34443077a19a6df76c44835ee Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:15:43 -0400 Subject: [PATCH 74/82] make the section bar an action prompt, not the current section The section bar showed the active section as its placeholder ("Section: X"), restating what the panel title already says and reading as a stored value. Make it an action prompt ("Switch sections") so the bar reads as navigation, and drop the now-unused active argument from every call site. The sub-nav keeps its "Viewing:" prefix: its buttons are all visible, so marking the current one helps, whereas the collapsed bar is better spent inviting the open. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/about/get.ts | 2 +- src/routes/delete-data/get.ts | 2 +- src/routes/filter/[scope]/get.ts | 6 ++---- src/routes/get.ts | 2 +- src/routes/help/catalog/get.ts | 5 +---- src/routes/help/get.ts | 5 +---- src/routes/lib/components.ts | 30 +++++++++++----------------- src/routes/lib/paging.test.ts | 4 +++- src/routes/mode/get.ts | 2 +- src/routes/recipients/[scope]/get.ts | 2 +- src/routes/ring/get.ts | 7 ++----- src/routes/signups/get.ts | 2 +- src/routes/signups/roles/_shared.ts | 2 +- 13 files changed, 28 insertions(+), 43 deletions(-) diff --git a/src/routes/about/get.ts b/src/routes/about/get.ts index 354a1c8..5e3e3a1 100644 --- a/src/routes/about/get.ts +++ b/src/routes/about/get.ts @@ -33,7 +33,7 @@ export const aboutGet: Handler<"GET"> = (router, interaction, state) => { .setStyle(ButtonStyle.Link) .setURL(SUPPORT_URL), ), - navBar(router, interaction, { active: "about" }), + navBar(router, interaction), ], }; }; diff --git a/src/routes/delete-data/get.ts b/src/routes/delete-data/get.ts index d75fc20..1f51f4e 100644 --- a/src/routes/delete-data/get.ts +++ b/src/routes/delete-data/get.ts @@ -34,7 +34,7 @@ export const deleteDataGet: Handler<"GET"> = (router, interaction, state) => { .setDisabled(done) .setTo(PANEL, { method: "MODAL" }), ), - navBar(router, interaction, { active: "delete" }), + navBar(router, interaction), ], }; }; diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index 84e57d7..e940824 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -92,9 +92,7 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { embeds: [ new EmbedBuilder() .setColor(COLOR) - .setTitle( - scope === "global" ? "🛡️ Global filter" : "🛡️ Channel filter", - ) + .setTitle(scope === "global" ? "🛡️ Global filter" : "🛡️ Channel filter") .setDescription(description), ], // top to bottom within the page: the scope select leads as context, then @@ -109,7 +107,7 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { showOptions: showOptionsOf(state.queryParams), options: filterOptions, }), - navBar(router, interaction, { active: "filters" }), + navBar(router, interaction), ], }; }; diff --git a/src/routes/get.ts b/src/routes/get.ts index 4ebd6c2..df9bc33 100644 --- a/src/routes/get.ts +++ b/src/routes/get.ts @@ -22,6 +22,6 @@ export const homeGet: Handler<"GET"> = (router, interaction, state) => { return { embeds: [embed], - components: [navBar(router, interaction, { active: "home" })], + components: [navBar(router, interaction)], }; }; diff --git a/src/routes/help/catalog/get.ts b/src/routes/help/catalog/get.ts index e949ed8..836dec0 100644 --- a/src/routes/help/catalog/get.ts +++ b/src/routes/help/catalog/get.ts @@ -87,9 +87,6 @@ export const catalogGet: Handler<"GET"> = (router, interaction, state) => { .setTitle("📖 Catalog") .setDescription(description), ], - components: [ - helpSubNav(router, "catalog"), - navBar(router, interaction, { active: "help" }), - ], + components: [helpSubNav(router, "catalog"), navBar(router, interaction)], }; }; diff --git a/src/routes/help/get.ts b/src/routes/help/get.ts index bc088f9..4f9dfa0 100644 --- a/src/routes/help/get.ts +++ b/src/routes/help/get.ts @@ -40,9 +40,6 @@ export const helpGet: Handler<"GET"> = (router, interaction, state) => { .setTitle("📖 Getting started") .setDescription(description), ], - components: [ - helpSubNav(router, "help"), - navBar(router, interaction, { active: "help" }), - ], + components: [helpSubNav(router, "help"), navBar(router, interaction)], }; }; diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 0b14ed7..df809c1 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -69,9 +69,7 @@ export const backButton = ( .setStyle(ButtonStyle.Secondary) .setTo(path); -// the top-level section a panel belongs to, so the section bar can mark it as -// the current selection. Panels with no section (Ring quick-panel, error -// states) pass nothing, and the menu shows its placeholder instead +// the top-level sections, one per entry in the section bar (SECTIONS below) export type Section = | "home" | "signups" @@ -98,15 +96,14 @@ const SECTIONS: readonly Tab[] = [ ]; // the persistent section bar every panel ends on, as a string select so all -// sections fit one row. The active section names the placeholder ("Section: …") -// rather than being a selected option, so the bar reads as a location label -// instead of a value picker; selecting any section routes there. The Signups -// option carries the branded voice-chat emoji when the bot can use it, else a -// unicode bell +// sections fit one row. Its placeholder is an action prompt ("Switch sections") +// rather than the current section: the panel title already names where you are, +// and a prompt reads as navigation instead of a stored value; selecting any +// section routes there. The Signups option carries the branded voice-chat emoji +// when the bot can use it, else a unicode bell export const navBar = ( router: RingRouter, interaction: Interaction, - { active }: { active?: Section }, ): APIActionRowComponent => { const vc = buttonEmoji(interaction, VC_EMOJI_ID); // the Ring section lands on the immediate ring action when the user is in a @@ -120,20 +117,18 @@ export const navBar = ( const option = ({ section, label, path }: Tab) => { const target = section === "ringees" && inVoice ? "/ring" : path; - const builder = new RouteStringSelectMenuOptionBuilder(router).setTo(target); + const builder = new RouteStringSelectMenuOptionBuilder(router).setTo( + target, + ); if (section === "signups" && vc) builder.setLabel("Signups").setEmoji(vc); else builder.setLabel(label); return builder; }; - const activeLabel = active - ? SECTIONS.find((tab) => tab.section === active)?.label - : undefined; - // each option carries its own encoded target, and the builder's default // pattern routes the submission to the selected one const select = new RouteStringSelectMenuBuilder(router) - .setPlaceholder(activeLabel ? `Section: ${activeLabel}` : "Jump to a section") + .setPlaceholder("Switch sections") .setTos(SECTIONS.map(option)); return new ActionRowBuilder() @@ -143,9 +138,8 @@ export const navBar = ( // the sub-view switch a section shows just above the bar when it has sibling // views (e.g. My signups / Role signups, Quick ring / Default ringees). The -// current view is an inert Primary prefixed "Viewing:" to name where you are, -// mirroring the section bar's "Section:" placeholder; the others are Secondary -// links +// current view is an inert Primary prefixed "Viewing:" to name where you are; +// the others are Secondary links export const subNav = ( router: RingRouter, items: { label: string; path: string; active?: boolean }[], diff --git a/src/routes/lib/paging.test.ts b/src/routes/lib/paging.test.ts index 61cb86d..ee7d9f3 100644 --- a/src/routes/lib/paging.test.ts +++ b/src/routes/lib/paging.test.ts @@ -47,7 +47,9 @@ test("a list that is an exact multiple of the page size adds no trailing page", test("the count line counts only pages that hold entries", () => { expect(pagedCountLine("Signups", 0)).toBe("**Signups** · None"); - expect(pagedCountLine("Signups", PAGE_SIZE)).toBe(`**Signups** · ${PAGE_SIZE}`); + expect(pagedCountLine("Signups", PAGE_SIZE)).toBe( + `**Signups** · ${PAGE_SIZE}`, + ); expect(pagedCountLine("Signups", PAGE_SIZE + 1)).toBe( `**Signups** · ${PAGE_SIZE + 1} across 2 pages`, ); diff --git a/src/routes/mode/get.ts b/src/routes/mode/get.ts index 6540798..49e7e85 100644 --- a/src/routes/mode/get.ts +++ b/src/routes/mode/get.ts @@ -44,7 +44,7 @@ export const modeGet: Handler<"GET"> = async (router, interaction, state) => { .setTo(PATH, { method: "POST", queryParams: { set: mode } }), ), ), - navBar(router, interaction, { active: "mode" }), + navBar(router, interaction), ], }; }; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 0643f25..15d9cfc 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -151,7 +151,7 @@ export const recipientsGet: Handler<"GET"> = async ( { label: "Quick ring", path: "/ring" }, { label: "Default ringees", path: panelPath(scope), active: true }, ]), - navBar(router, interaction, { active: "ringees" }), + navBar(router, interaction), ], }; }; diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index 103750e..ba2369c 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -30,10 +30,7 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { .setTitle("📣 Quick ring") .setDescription(`⚠️ ${noVoiceChannelFlash(interaction)}`), ], - components: [ - ringViews, - navBar(router, interaction, { active: "ringees" }), - ], + components: [ringViews, navBar(router, interaction)], }; const defaults = await getAllDefaultRingees(interaction.user.id, channel.id); @@ -67,7 +64,7 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { .setTo(`${PANEL}/default`, { method: "POST" }), ), ringViews, - navBar(router, interaction, { active: "ringees" }), + navBar(router, interaction), ], }; }; diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index c9cfab0..55322c8 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -96,7 +96,7 @@ export const signupsGet: Handler<"GET"> = async ( ]), ] : []), - navBar(router, interaction, { active: "signups" }), + navBar(router, interaction), ], }; }; diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index 9bc18dd..578f21c 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -168,6 +168,6 @@ export const roleFrame = ({ { label: "My signups", path: PANEL }, { label: "Role signups", path: ROLES, active: true }, ]), - navBar(router, interaction, { active: "signups" }), + navBar(router, interaction), ], }); From 0045026e0c5caace45104e9bb0277bb45a58f7db Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:20:35 -0400 Subject: [PATCH 75/82] show panel options directly on single-page lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hiding every panel's options behind a ⚙ toggle buried controls like the filter type switch even when the row was free. Show the option buttons directly whenever there's no pager competing for the row (the common single-page case); the toggle now appears only when pagination needs the row too. Destructive actions stay safe through their own confirm modal, not the toggle. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/lib/components.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index df809c1..0dbd19b 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -157,12 +157,12 @@ export const subNav = ( ), ); -// a paged panel's control row: the page controls with a switch into the -// panel's option buttons (reset and friends), or the options with the way -// back, keyed by the `options` query param. Options always hide behind the -// toggle — destructive buttons like Reset stay one deliberate click away — -// so a single-page list shows just the toggle; a panel with no options and -// one page contributes no row, so the result is spread into a components +// a paged panel's control row. With one page (the common case) the option +// buttons show directly — no toggle burying the panel's controls; destructive +// ones (Reset) stay safe behind their own confirm modal. Only when a pager +// needs the row too does a ⚙ toggle appear, swapping between the page controls +// and the options, keyed by the `options` query param. A panel with no options +// and one page contributes no row, so the result is spread into a components // array export const pagedControls = ( router: RingRouter, @@ -204,9 +204,11 @@ export const pagedControls = ( pageButton("Next ▶", page + 1, page === pageCount - 1), ]; if (options.length === 0) return pager.length > 0 ? [row(...pager)] : []; - // the toggle leads the row in both modes — the leading slot is the only - // position variable-width neighbors can't shift; open options lead with - // the way back to the page controls + // no pager competing for the row: the options own it and show directly + if (pager.length === 0) return [row(...options)]; + // a pager shares the row, so the ⚙ toggle swaps between the two; it leads + // the row (a fixed slot the variable-width pager can't shift), and the open + // options lead with the way back to the page controls if (showOptions) return [ row( From f7a37a3a273ecc4c08983f4b967a35ca78913eb9 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:23:40 -0400 Subject: [PATCH 76/82] grey the Reset buttons, reserve red for deleting everything Every panel's Reset was Danger-red, pulling the eye to a rare action and away from the content. Reset is already modal-guarded (type-to-confirm), so the red wasn't the safeguard. Make the five Resets Secondary and keep red only for the Delete-data section's "Delete everything", so red means the one point-of-no-return action. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/filter/[scope]/get.ts | 2 +- src/routes/recipients/[scope]/get.ts | 2 +- src/routes/signups/get.ts | 2 +- src/routes/signups/roles/by-channel/get.ts | 2 +- src/routes/signups/roles/by-role/get.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index e940824..a2faca0 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -84,7 +84,7 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { }), new RouteButtonBuilder(router) .setLabel("Reset") - .setStyle(ButtonStyle.Danger) + .setStyle(ButtonStyle.Secondary) .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), ]; diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 15d9cfc..17448e4 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -122,7 +122,7 @@ export const recipientsGet: Handler<"GET"> = async ( }), new RouteButtonBuilder(router) .setLabel("Reset") - .setStyle(ButtonStyle.Danger) + .setStyle(ButtonStyle.Secondary) .setTo(`${panelPath(scope)}/reset`, { method: "MODAL" }), ]; diff --git a/src/routes/signups/get.ts b/src/routes/signups/get.ts index 55322c8..600abd1 100644 --- a/src/routes/signups/get.ts +++ b/src/routes/signups/get.ts @@ -82,7 +82,7 @@ export const signupsGet: Handler<"GET"> = async ( options: [ new RouteButtonBuilder(router) .setLabel("Reset") - .setStyle(ButtonStyle.Danger) + .setStyle(ButtonStyle.Secondary) .setTo(`${PANEL}/reset`, { method: "MODAL" }), ], }), diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index 3dbcfed..b591eee 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -93,7 +93,7 @@ export const rolesByChannelGet: Handler<"GET"> = async ( options: [ new RouteButtonBuilder(router) .setLabel("Reset") - .setStyle(ButtonStyle.Danger) + .setStyle(ButtonStyle.Secondary) .setTo(`${BY_CHANNEL}/${scope}/reset`, { method: "MODAL" }), ], }), diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index fe1d2e6..8b7211f 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -97,7 +97,7 @@ export const rolesByRoleGet: Handler<"GET"> = async ( options: [ new RouteButtonBuilder(router) .setLabel("Reset") - .setStyle(ButtonStyle.Danger) + .setStyle(ButtonStyle.Secondary) .setTo(`${BY_ROLE}/${scope}/reset`, { method: "MODAL" }), ], }), From 1d2648f73657b3e51cabfaef29f7e68fa6b07db3 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:28:17 -0400 Subject: [PATCH 77/82] warn harder in the confirm modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modals can't be colored, so signal danger through text instead: prefix every confirm title with ⚠️ and add a "This can't be undone." line under the input. Raise the delete-everything confirmation word from DELETE to DELETE EVERYTHING so the nuclear action takes a deliberate phrase, and have its test reference the constant instead of the literal. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/delete-data/_shared.ts | 2 +- src/routes/delete-data/delete-data.test.ts | 7 ++++--- src/routes/lib/confirm.ts | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/routes/delete-data/_shared.ts b/src/routes/delete-data/_shared.ts index 1dd1c83..b896830 100644 --- a/src/routes/delete-data/_shared.ts +++ b/src/routes/delete-data/_shared.ts @@ -1,2 +1,2 @@ export const PANEL = "/delete-data"; -export const CONFIRMATION = "DELETE"; +export const CONFIRMATION = "DELETE EVERYTHING"; diff --git a/src/routes/delete-data/delete-data.test.ts b/src/routes/delete-data/delete-data.test.ts index b59656a..6a4dd20 100644 --- a/src/routes/delete-data/delete-data.test.ts +++ b/src/routes/delete-data/delete-data.test.ts @@ -3,6 +3,7 @@ import { beforeEach, expect, test, vi } from "vitest"; import { deleteAllUserData } from "@db/users"; +import { CONFIRMATION } from "./_shared"; import { deleteDataPost } from "./post"; vi.mock("@db/users", () => ({ @@ -24,13 +25,13 @@ beforeEach(() => { vi.clearAllMocks(); }); -test("typing DELETE exactly deletes all user data", async () => { +test("typing the confirmation word exactly deletes all user data", async () => { vi.mocked(deleteAllUserData).mockResolvedValue(true); const result = await deleteDataPost( undefined as never, interaction, - state("DELETE"), + state(CONFIRMATION), ); expect(deleteAllUserData).toHaveBeenCalledExactlyOnceWith("caller"); @@ -46,7 +47,7 @@ test("a mismatched confirmation deletes nothing", async () => { const result = await deleteDataPost( undefined as never, interaction, - state("delete"), + state(CONFIRMATION.toLowerCase()), ); expect(deleteAllUserData).not.toHaveBeenCalled(); diff --git a/src/routes/lib/confirm.ts b/src/routes/lib/confirm.ts index a4fb2c0..eb990ea 100644 --- a/src/routes/lib/confirm.ts +++ b/src/routes/lib/confirm.ts @@ -16,10 +16,11 @@ export const confirmModal = ( ): RingModal => new RouteModalBuilder(router) .setTo(to, { method: "POST", flags: [MessageFlags.Ephemeral] }) - .setTitle(title) + .setTitle(`⚠️ ${title}`) .addLabelComponents( new LabelBuilder() .setLabel(`Type ${word} to confirm`) + .setDescription("This can't be undone.") .setTextInputComponent( new TextInputBuilder() .setCustomId("confirm") From 37c51e7fcacf228246ca8799ef745453d78602f1 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:31:39 -0400 Subject: [PATCH 78/82] make both auto-ring toggle states primary The toggle switched between green (enable) and grey (disable), so its color flip read as a state change on its own. Use Primary for both and let the label carry enable vs disable. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/recipients/[scope]/get.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 17448e4..2f7c796 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -113,9 +113,7 @@ export const recipientsGet: Handler<"GET"> = async ( const ringOptions = [ new RouteButtonBuilder(router) .setLabel(autoRing.effective ? "Disable auto-ring" : "Enable auto-ring") - .setStyle( - autoRing.effective ? ButtonStyle.Secondary : ButtonStyle.Success, - ) + .setStyle(ButtonStyle.Primary) .setTo(`${panelPath(scope)}/auto-ring`, { method: "POST", queryParams: { enable: autoRing.effective ? "0" : "1" }, From 42767314378f8404e7c0af9457f267e7e738b6ae Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:40:19 -0400 Subject: [PATCH 79/82] change some buttons to secondary color --- src/routes/filter/[scope]/get.ts | 2 +- src/routes/recipients/[scope]/get.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index a2faca0..0a31f67 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -77,7 +77,7 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { .setLabel( type === "blacklist" ? "Switch to Whitelist" : "Switch to Blacklist", ) - .setStyle(ButtonStyle.Primary) + .setStyle(ButtonStyle.Secondary) .setTo(`${panelPath(scope)}/type`, { method: "POST", queryParams: { to: type === "blacklist" ? "whitelist" : "blacklist" }, diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 2f7c796..46d7085 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -113,7 +113,7 @@ export const recipientsGet: Handler<"GET"> = async ( const ringOptions = [ new RouteButtonBuilder(router) .setLabel(autoRing.effective ? "Disable auto-ring" : "Enable auto-ring") - .setStyle(ButtonStyle.Primary) + .setStyle(ButtonStyle.Secondary) .setTo(`${panelPath(scope)}/auto-ring`, { method: "POST", queryParams: { enable: autoRing.effective ? "0" : "1" }, From dd326d099e6e2e00307f19d2a5fa43693a18fe16 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:24:28 -0400 Subject: [PATCH 80/82] drop the unused backButton label param Its one caller passes only (router, path); the label always defaults to "Back". Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/lib/components.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 0dbd19b..43abaf9 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -59,13 +59,9 @@ export const homeButton = (router: RingRouter): RingButton => .setStyle(ButtonStyle.Secondary) .setTo("/"); -export const backButton = ( - router: RingRouter, - path: string, - label = "Back", -): RingButton => +export const backButton = (router: RingRouter, path: string): RingButton => new RouteButtonBuilder(router) - .setLabel(label) + .setLabel("Back") .setStyle(ButtonStyle.Secondary) .setTo(path); From 752519107cf9aac145f5c4c3d3afa099653b1f5d Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:24:28 -0400 Subject: [PATCH 81/82] make unsignuprole fall back to the current channel and panel Mirror signup/unsignup/signuprole: resolve the current voice channel when no channel option is given, and open the by-role panel when there is no channel context instead of jumping to the reset-all modal. Bulk reset stays available behind the panel's Reset button. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/unsignuprole.ts | 46 ++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/commands/unsignuprole.ts b/src/commands/unsignuprole.ts index b6a1dfb..53f2022 100644 --- a/src/commands/unsignuprole.ts +++ b/src/commands/unsignuprole.ts @@ -20,33 +20,33 @@ export const unsignuprole = { .addChannelOption((option) => option .setName("channel") - .setDescription( - "Select the channel to remove the role from (all channels if omitted)", - ) + .setDescription("Select the channel to remove the role from") .addChannelTypes(ChannelType.GuildVoice) .setRequired(false), ), async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { const role = interaction.options.getRole("role", true); - const channel = interaction.options.getChannel("channel"); - // a channel removes just that link; clearing every channel is - // destructive, so it goes through the same typed-confirmation modal as - // the panel's Reset button - if (channel) - await router.dispatch( - interaction, - `/signups/roles/by-role/${role.id}/channels`, - { - method: "POST", - queryParams: { remove: channel.id }, - flags: [MessageFlags.Ephemeral], - }, - ); - else - await router.dispatch( - interaction, - `/signups/roles/by-role/${role.id}/reset`, - { method: "MODAL" }, - ); + const channel = + interaction.options.getChannel("channel") ?? + (interaction.channel?.type === ChannelType.GuildVoice + ? interaction.channel + : null); + // without a channel, open the role's panel to pick which to remove; the + // panel's Reset button covers clearing every channel behind its confirm + if (!channel) { + await router.dispatch(interaction, `/signups/roles/by-role/${role.id}`, { + flags: [MessageFlags.Ephemeral], + }); + return; + } + await router.dispatch( + interaction, + `/signups/roles/by-role/${role.id}/channels`, + { + method: "POST", + queryParams: { remove: channel.id }, + flags: [MessageFlags.Ephemeral], + }, + ); }, }; From 9251ea11391594ba704cefcace16de4f4a9b434c Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:29:39 -0400 Subject: [PATCH 82/82] trim route-layer comments to the non-obvious Compress paragraph-length comments across the new route layer to one or two lines, keeping only the load-bearing rationale (per-render customId reset, the add-page rule, off-page alreadyPresent semantics, the blacklist/whitelist guard) and cutting restatement. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/filter/[scope]/get.ts | 5 +- src/routes/filter/[scope]/members/post.ts | 5 +- src/routes/filter/[scope]/type/post.ts | 5 +- src/routes/index.ts | 10 ++-- src/routes/lib/components.ts | 55 +++++++------------ src/routes/lib/flash.ts | 10 ++-- src/routes/lib/mentions.ts | 5 +- src/routes/lib/paging.ts | 42 ++++++-------- src/routes/recipients/[scope]/get.ts | 5 +- src/routes/signups/roles/_shared.ts | 12 ++-- .../signups/roles/by-channel/roles/post.ts | 5 +- .../signups/roles/by-role/channels/post.ts | 5 +- 12 files changed, 63 insertions(+), 101 deletions(-) diff --git a/src/routes/filter/[scope]/get.ts b/src/routes/filter/[scope]/get.ts index 0a31f67..a64c959 100644 --- a/src/routes/filter/[scope]/get.ts +++ b/src/routes/filter/[scope]/get.ts @@ -95,9 +95,8 @@ export const filterGet: Handler<"GET"> = async (router, interaction, state) => { .setTitle(scope === "global" ? "🛡️ Global filter" : "🛡️ Channel filter") .setDescription(description), ], - // top to bottom within the page: the scope select leads as context, then - // the member list and its control row (pager or the filter-wide options); - // the section bar is the global nav pinned at the bottom + // top to bottom: the scope select leads as context, then the member list + // and its control row; the section bar is the global nav pinned at the bottom components: [ scopeSelect, editSelect, diff --git a/src/routes/filter/[scope]/members/post.ts b/src/routes/filter/[scope]/members/post.ts index f9c7bc2..1c52f7f 100644 --- a/src/routes/filter/[scope]/members/post.ts +++ b/src/routes/filter/[scope]/members/post.ts @@ -17,9 +17,8 @@ import { Handler } from "@routes/types"; import { panelPath } from "../../_shared"; // one members editor serves the panel select and the /block, /unblock, -// /whitelist and /unwhitelist adapters; `intent` keys the guard that stops -// blocklist-style edits on a whitelist (and vice versa) so the rule exists -// exactly once +// /whitelist, /unwhitelist adapters; `intent` keys the guard that stops +// blocklist edits on a whitelist (and vice versa), keeping the rule in one place export const filterMembersPost: Handler<"POST"> = async ( router, interaction, diff --git a/src/routes/filter/[scope]/type/post.ts b/src/routes/filter/[scope]/type/post.ts index d7a9044..2bc5fab 100644 --- a/src/routes/filter/[scope]/type/post.ts +++ b/src/routes/filter/[scope]/type/post.ts @@ -26,9 +26,8 @@ export const filterTypePost: Handler<"POST"> = async ( await setFilterType(interaction.user.id, channelId, to === "whitelist"); - // the entries carry over and the switch reverses what they mean, so the - // notice has to say so: a blacklist's blocked users become the only people - // a whitelist lets through, and vice versa + // entries carry over and the switch reverses their meaning, so the notice + // says so: a blacklist's blocked users become the only ones a whitelist admits const lead = `${scopeName(scope, "filter", { capitalize: true })} is now a ${to}`; if ((filter?.entries.size ?? 0) === 0) return flashRedirect(panel, lead, "success"); diff --git a/src/routes/index.ts b/src/routes/index.ts index fc7c708..14178fe 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -47,9 +47,8 @@ export const registerRoutes = (router: RingRouter) => { router.get("/help/catalog", catalogGet); router.get("/about", aboutGet); - // scoped panels answer their bare path as the global scope, so their - // scope-switch channel select can target "{/:channelId}" and fall back to - // global when the selection is cleared + // scoped panels answer their bare path as the global scope, so the scope-switch + // select can target "{/:channelId}" and fall back to global when cleared router.get(["/filter", "/filter/:scope"], filterGet); router.post("/filter/:scope/members", filterMembersPost); router.post("/filter/:scope/type", filterTypePost); @@ -79,9 +78,8 @@ export const registerRoutes = (router: RingRouter) => { post: signupsResetPost, }); - // role signups open on a neutral view offering a channel select and a role - // select; picking either sets the orientation and routes to its scoped view. - // Clearing a scope drops back to the bare orientation path, which is neutral + // role signups open on a neutral view with a channel select and a role select; + // picking either sets the orientation. Clearing a scope returns to the neutral path router.get( ["/signups/roles", "/signups/roles/by-channel", "/signups/roles/by-role"], rolesGet, diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 43abaf9..a3e2c71 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -27,10 +27,8 @@ export const row = ( // the add/remove select every paged member panel shares: the page's entries // ride in as the caller-set defaults, and selecting or deselecting them posts -// the diff. The caller supplies the entity-specific builder (with its default -// entries and any channel-type filter already set) and the noun the shared -// placeholder reads; the row-reset key keeps a stale client selection from -// surviving the next render +// the diff. The caller supplies the builder (defaults and channel-type filter +// already set) and the noun the shared placeholder reads export const editSelectRow = < B extends | RouteUserSelectMenuBuilder @@ -78,8 +76,7 @@ export type Section = type Tab = { section: Section; label: string; path: string }; -// every section in one menu; a string select holds all of them, so there is -// no five-button row cap to page around +// every section the bar offers, in display order const SECTIONS: readonly Tab[] = [ { section: "home", label: "🏠 Home", path: "/" }, { section: "signups", label: "🔔 Signups", path: "/signups" }, @@ -91,20 +88,16 @@ const SECTIONS: readonly Tab[] = [ { section: "delete", label: "🗑️ Delete data", path: "/delete-data" }, ]; -// the persistent section bar every panel ends on, as a string select so all -// sections fit one row. Its placeholder is an action prompt ("Switch sections") -// rather than the current section: the panel title already names where you are, -// and a prompt reads as navigation instead of a stored value; selecting any -// section routes there. The Signups option carries the branded voice-chat emoji -// when the bot can use it, else a unicode bell +// the persistent section bar every panel ends on. A string select fits all +// sections in one row (dodging the five-button cap); the placeholder is an +// action prompt since the panel title already names where you are export const navBar = ( router: RingRouter, interaction: Interaction, ): APIActionRowComponent => { const vc = buttonEmoji(interaction, VC_EMOJI_ID); - // the Ring section lands on the immediate ring action when the user is in a - // voice channel, and on the default-recipients settings otherwise, so it - // never opens the "not in a voice channel" notice by default + // the Ring option lands on the immediate ring action when in a voice channel, + // else the default-recipients settings, so it never opens the "not in VC" notice const inVoice = !!( interaction.member && "voice" in interaction.member && @@ -121,8 +114,7 @@ export const navBar = ( return builder; }; - // each option carries its own encoded target, and the builder's default - // pattern routes the submission to the selected one + // each option encodes its own target; the default pattern routes to the picked one const select = new RouteStringSelectMenuBuilder(router) .setPlaceholder("Switch sections") .setTos(SECTIONS.map(option)); @@ -132,10 +124,8 @@ export const navBar = ( .toJSON(); }; -// the sub-view switch a section shows just above the bar when it has sibling -// views (e.g. My signups / Role signups, Quick ring / Default ringees). The -// current view is an inert Primary prefixed "Viewing:" to name where you are; -// the others are Secondary links +// the sub-view switch a section shows above the bar when it has sibling views. +// The active view is an inert Primary prefixed "Viewing:"; the others are links export const subNav = ( router: RingRouter, items: { label: string; path: string; active?: boolean }[], @@ -146,20 +136,15 @@ export const subNav = ( .setLabel(item.active ? `Viewing: ${item.label}` : item.label) .setStyle(item.active ? ButtonStyle.Primary : ButtonStyle.Secondary) .setDisabled(item.active ?? false) - // a sub-nav item can point at the same path as the bar's active tab - // (e.g. Default ringees / the Ring tab); the key keeps their - // customIds distinct, which Discord requires within one message + // a sub-nav item can share a path with the bar's active tab; the key + // keeps their customIds distinct, which Discord requires per message .setTo(item.path, { key: "subnav" }), ), ); -// a paged panel's control row. With one page (the common case) the option -// buttons show directly — no toggle burying the panel's controls; destructive -// ones (Reset) stay safe behind their own confirm modal. Only when a pager -// needs the row too does a ⚙ toggle appear, swapping between the page controls -// and the options, keyed by the `options` query param. A panel with no options -// and one page contributes no row, so the result is spread into a components -// array +// a paged panel's control row. With one page the options show directly; only +// when a pager also needs the row does a ⚙ toggle swap between page controls +// and options, keyed by the `options` query param. No pager, no options: no row export const pagedControls = ( router: RingRouter, basePath: string, @@ -184,8 +169,7 @@ export const pagedControls = ( ? [] : [ pageButton("◀ Prev", page - 1, page === 0), - // the middle button doubles as a page jump: it opens a modal asking - // which page to show, which redirects back to basePath + // the middle button opens a page-jump modal that redirects back to basePath new RouteButtonBuilder(router) .setLabel(`Page ${page + 1} of ${pageCount}`) .setStyle(ButtonStyle.Secondary) @@ -202,9 +186,8 @@ export const pagedControls = ( if (options.length === 0) return pager.length > 0 ? [row(...pager)] : []; // no pager competing for the row: the options own it and show directly if (pager.length === 0) return [row(...options)]; - // a pager shares the row, so the ⚙ toggle swaps between the two; it leads - // the row (a fixed slot the variable-width pager can't shift), and the open - // options lead with the way back to the page controls + // pager and options share the row: the ⚙ toggle leads (a fixed slot the + // variable-width pager can't shift), and the open options lead with a way back if (showOptions) return [ row( diff --git a/src/routes/lib/flash.ts b/src/routes/lib/flash.ts index c71d03c..fa15af4 100644 --- a/src/routes/lib/flash.ts +++ b/src/routes/lib/flash.ts @@ -2,10 +2,9 @@ import { RouteRedirect } from "discord-embed-router"; export type FlashLevel = "success" | "warn"; -// the redirect a mutation handler returns so its outcome shows as a notice -// on the target panel. The flash lives only in the in-flight dispatch: the -// target GET receives it through state.queryParams and the components it -// builds don't carry it, so the notice clears on the next interaction +// the redirect a mutation handler returns so its outcome shows as a notice on +// the target panel. It rides the in-flight query params only — the components +// the GET builds don't carry it — so the notice clears on the next interaction export const flashRedirect = ( redirect: string, flash: string, @@ -25,8 +24,7 @@ const boldLead = (text: string): string => { }; // the notice a panel renders at the bottom of its embed, as a markdown -// blockquote (Discord draws a grey left bar) so it separates from the body; -// the level picks the icon and the opening clause is bolded +// blockquote so it separates from the body; the level picks the icon export const flashLine = (queryParams: URLSearchParams): string | null => { const flash = queryParams.get("flash"); if (!flash) return null; diff --git a/src/routes/lib/mentions.ts b/src/routes/lib/mentions.ts index 3169c81..d657563 100644 --- a/src/routes/lib/mentions.ts +++ b/src/routes/lib/mentions.ts @@ -1,9 +1,8 @@ import { CommandName } from "@commands/commandNames"; import { Globals } from "@routes/types"; -// a clickable command mention when the deployed id is known, plain text -// otherwise (ids are fetched after the client is ready, so early -// interactions may miss them) +// a clickable command mention when the deployed id is known, else plain text +// (ids are fetched after the client is ready, so early interactions may miss them) export const commandMention = ( globals: Globals | undefined, name: CommandName, diff --git a/src/routes/lib/paging.ts b/src/routes/lib/paging.ts index 58b6f53..d0b24c3 100644 --- a/src/routes/lib/paging.ts +++ b/src/routes/lib/paging.ts @@ -11,20 +11,16 @@ export type Page = { pageCount: number; }; -// the "**Label** · 23 across 3 pages" line paged panels show instead of -// repeating the entries the select below already displays as its selection; -// the pager button carries which page is being viewed. Counts only pages -// that hold entries, so pageCountOf's trailing add page never inflates it +// the "**Label** · 23 across 3 pages" summary line, counting only pages that +// hold entries so pageCountOf's trailing add page never inflates the count export const pagedCountLine = (label: string, total: number): string => { const contentPages = Math.ceil(total / PAGE_SIZE); return `**${label}** · ${total > 0 ? total : "None"}${contentPages > 1 ? ` across ${contentPages} pages` : ""}`; }; -// setPattern options for a paged edit select's POST. The per-render key -// gives every render a fresh customId: the Discord client keeps a select's -// in-flight selection when a message edit leaves the component unchanged, -// so without it a submission's picks would stay visibly selected instead -// of resetting to the page's default values +// setPattern options for a paged edit select's POST. The per-render key gives +// every render a fresh customId: Discord keeps a select's in-flight selection +// when a message edit leaves the component unchanged, so a fresh id resets it export const pagedEditPattern = ( page: number, timestamp: number, @@ -35,10 +31,9 @@ export const pagedEditPattern = ( key: timestamp.toString(36), }); -// how many pages a list of this size spans. A full last page only gets a -// trailing empty page when a page fills the whole edit select (PAGE_SIZE >= -// SELECT_MAX_VALUES), since otherwise the select still has room to add -// entries on the last page; an empty list still shows one page +// how many pages a list spans. A full last page gets a trailing empty page +// only when a page fills the whole edit select (PAGE_SIZE >= SELECT_MAX_VALUES); +// otherwise the last page still has room to add entries. Empty list: one page export const pageCountOf = (total: number): number => { const fullPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const needsAddPage = @@ -59,11 +54,10 @@ export const paginate = (items: string[], rawPage: string | null): Page => { }; }; -// diffs a select submission against the visible page: entries missing from -// the submission are removals, submitted values not in the whole list are -// additions. Submitted values that live in the list but on another page are -// no-ops (pages stay independent) and are reported as `alreadyPresent` so -// flashes can point at the existing entry instead of claiming "no changes" +// diffs a select submission against the visible page: values missing from the +// submission are removals, values not in the whole list are additions. Values +// on another page are no-ops, reported as `alreadyPresent` so the flash can +// point at the existing entry instead of claiming "no changes" export const diffSelection = ({ allItems, pageItems, @@ -85,9 +79,8 @@ export const diffSelection = ({ }; }; -// labels a value with the page that displays it, e.g. "#general (page 2)", -// so a flash can point at where an entry lives — except on the page in -// view, where the entry is already visible and the label is noise +// labels a value with the page that displays it, e.g. "#general (page 2)", so a +// flash points at where an entry lives; skipped on the page already in view export const withPageLabel = (allItems: string[], mention: (id: string) => string, viewedPage: number) => (value: string): string => { @@ -97,10 +90,9 @@ export const withPageLabel = : `${mention(value)} (page ${page + 1})`; }; -// resolves an add/remove edit for a paged member select from either input a -// handler can receive: a select submission (diffed against the visible page) -// or `add`/`remove` query params from a command adapter. Callers still filter -// the result against their current set before mutating +// resolves an add/remove edit from either input a handler gets: a select +// submission (diffed against the page) or `add`/`remove` query params from a +// command adapter. Callers still filter against their current set before mutating export const resolveSelectionEdit = ({ current, values, diff --git a/src/routes/recipients/[scope]/get.ts b/src/routes/recipients/[scope]/get.ts index 46d7085..3635bbe 100644 --- a/src/routes/recipients/[scope]/get.ts +++ b/src/routes/recipients/[scope]/get.ts @@ -133,9 +133,8 @@ export const recipientsGet: Handler<"GET"> = async ( ) .setDescription(description), ], - // top to bottom within the page: the scope select leads as context, then - // the ringee list and its control row (pager or the panel options); the - // sub-nav and section bar are navigation pinned at the bottom + // top to bottom: the scope select leads as context, then the ringee list + // and its control row; the sub-nav and section bar are pinned at the bottom components: [ scopeSelect, editSelect, diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index 578f21c..9486aac 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -73,10 +73,9 @@ export const sortChannelIds = (guild: Guild, ids: string[]): string[] => ), ); -// applies an add/remove edit against a scope's current links and reports the -// outcome as a flash. Adds/removes are filtered against the current set so -// the notice reflects real changes; both edit orientations mutate the same -// (channel, role) table through the passed callbacks +// applies an add/remove edit against a scope's current links and reports it as +// a flash. Adds/removes are filtered against the current set so the notice +// reflects real changes; both orientations mutate the same table via callbacks export const commitRoleEdit = async ({ redirect, page, @@ -140,9 +139,8 @@ export const commitRoleEdit = async ({ }); }; -// the frame every role-signups view ends in. Rows read low-to-high level top to -// bottom: the caller's content rows, then the My signups / Role signups switch, -// then the section bar +// the frame every role-signups view ends in: the caller's content rows, then +// the My signups / Role signups switch, then the section bar export const roleFrame = ({ router, interaction, diff --git a/src/routes/signups/roles/by-channel/roles/post.ts b/src/routes/signups/roles/by-channel/roles/post.ts index e30af0a..215f6d9 100644 --- a/src/routes/signups/roles/by-channel/roles/post.ts +++ b/src/routes/signups/roles/by-channel/roles/post.ts @@ -14,9 +14,8 @@ import { sortRoleIds, } from "../../_shared"; -// edits the roles signed up to one voice channel. Serves the panel's role -// multi-select (diffed against the shown page) and the /signuprole adapter -// (an `add` query param); the scope is the channel id in the path +// edits the roles signed up to one voice channel: the panel's role select +// (diffed against the page) and the /signuprole adapter's `add` query param export const rolesByChannelEditPost: Handler<"POST"> = async ( router, interaction, diff --git a/src/routes/signups/roles/by-role/channels/post.ts b/src/routes/signups/roles/by-role/channels/post.ts index 80e3020..fb643aa 100644 --- a/src/routes/signups/roles/by-role/channels/post.ts +++ b/src/routes/signups/roles/by-role/channels/post.ts @@ -15,9 +15,8 @@ import { } from "../../_shared"; import { guildVoiceChannelIds } from "../../../_shared"; -// edits the channels one role is signed up to. Serves the panel's channel -// multi-select (diffed against the shown page) and the /unsignuprole adapter -// (a `remove` query param); the scope is the role id in the path +// edits the channels one role is signed up to: the panel's channel select +// (diffed against the page) and the /unsignuprole adapter's `remove` query param export const rolesByRoleEditPost: Handler<"POST"> = async ( router, interaction,