From 9528ccf84b4a95d75bcda71e1c54fc23c046d191 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:13:40 -0400 Subject: [PATCH 1/9] Show slash-command mutations as a compact notice with an Open panel button Component and modal mutations keep the flash-on-panel redirect. Co-Authored-By: Claude Fable 5 --- src/routes/delete-data/delete-data.test.ts | 5 +- src/routes/delete-data/post.ts | 2 + src/routes/filter/[scope]/members/post.ts | 14 +++- src/routes/filter/[scope]/reset/post.ts | 3 + src/routes/filter/[scope]/type/post.ts | 4 +- src/routes/filter/filter.test.ts | 5 +- src/routes/index.ts | 3 + src/routes/lib/flash.test.ts | 36 ++++++++- src/routes/lib/flash.ts | 32 ++++++-- src/routes/mode/mode.test.ts | 5 +- src/routes/mode/post.ts | 21 ++++- src/routes/notice/get.ts | 38 +++++++++ src/routes/notice/notice.test.ts | 79 +++++++++++++++++++ src/routes/page-jump/page-jump.test.ts | 5 +- src/routes/page-jump/post.ts | 12 ++- .../recipients/[scope]/auto-ring/post.ts | 3 + src/routes/recipients/[scope]/members/post.ts | 1 + src/routes/recipients/[scope]/reset/post.ts | 3 + src/routes/recipients/recipients.test.ts | 5 +- src/routes/ring/_shared.ts | 17 +++- src/routes/ring/default/post.ts | 11 ++- src/routes/ring/ring.test.ts | 2 + src/routes/signups/members/post.ts | 2 + src/routes/signups/reset/post.ts | 10 ++- src/routes/signups/roles/_shared.ts | 19 ++++- .../signups/roles/by-channel/reset/post.ts | 3 + .../signups/roles/by-channel/roles/post.ts | 1 + .../signups/roles/by-role/channels/post.ts | 1 + .../signups/roles/by-role/reset/post.ts | 3 + src/routes/signups/signups.test.ts | 1 + 30 files changed, 311 insertions(+), 35 deletions(-) create mode 100644 src/routes/notice/get.ts create mode 100644 src/routes/notice/notice.test.ts diff --git a/src/routes/delete-data/delete-data.test.ts b/src/routes/delete-data/delete-data.test.ts index 6a4dd20..e2f6934 100644 --- a/src/routes/delete-data/delete-data.test.ts +++ b/src/routes/delete-data/delete-data.test.ts @@ -10,7 +10,10 @@ vi.mock("@db/users", () => ({ deleteAllUserData: vi.fn(), })); -const interaction = { user: { id: "caller" } } as unknown as Interaction; +const interaction = { + user: { id: "caller" }, + isChatInputCommand: () => false, +} as unknown as Interaction; const state = (confirmation: string) => ({ diff --git a/src/routes/delete-data/post.ts b/src/routes/delete-data/post.ts index d39d1be..b81c90b 100644 --- a/src/routes/delete-data/post.ts +++ b/src/routes/delete-data/post.ts @@ -12,6 +12,7 @@ export const deleteDataPost: Handler<"POST"> = async ( ) => { if (!confirmed(state.fields, CONFIRMATION)) return flashRedirect( + interaction, PANEL, "Confirmation text did not match, nothing was deleted", "warn", @@ -19,6 +20,7 @@ export const deleteDataPost: Handler<"POST"> = async ( const hadData = await deleteAllUserData(interaction.user.id); return flashRedirect( + interaction, PANEL, hadData ? "All your data has been deleted" diff --git a/src/routes/filter/[scope]/members/post.ts b/src/routes/filter/[scope]/members/post.ts index 1c52f7f..1359f11 100644 --- a/src/routes/filter/[scope]/members/post.ts +++ b/src/routes/filter/[scope]/members/post.ts @@ -36,12 +36,14 @@ export const filterMembersPost: Handler<"POST"> = async ( if (intent === "block" && type === "whitelist") return flashRedirect( + interaction, 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( + interaction, panel, "Your global filter is a blacklist. Switch it to a whitelist below, or use /block and /unblock instead.", "warn", @@ -116,7 +118,13 @@ export const filterMembersPost: Handler<"POST"> = async ( : `No changes to ${scopeName(scope, type)}`; } - return flashRedirect(panel, flash, changed ? "success" : "warn", { - page: query.get("page") ?? "0", - }); + return flashRedirect( + interaction, + panel, + flash, + changed ? "success" : "warn", + { + page: query.get("page") ?? "0", + }, + ); }; diff --git a/src/routes/filter/[scope]/reset/post.ts b/src/routes/filter/[scope]/reset/post.ts index 627a34e..ca53922 100644 --- a/src/routes/filter/[scope]/reset/post.ts +++ b/src/routes/filter/[scope]/reset/post.ts @@ -15,6 +15,7 @@ export const filterResetPost: Handler<"POST"> = async ( const panel = panelPath(scope); if (!confirmed(state.fields, "RESET")) return flashRedirect( + interaction, panel, "Confirmation text did not match, the filter was not reset", "warn", @@ -25,11 +26,13 @@ export const filterResetPost: Handler<"POST"> = async ( ); return wasNotDefault ? flashRedirect( + interaction, panel, `${scopeName(scope, "filter", { capitalize: true })} has been reset and is an empty blacklist`, "success", ) : flashRedirect( + interaction, 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 index 2bc5fab..449c2f7 100644 --- a/src/routes/filter/[scope]/type/post.ts +++ b/src/routes/filter/[scope]/type/post.ts @@ -19,6 +19,7 @@ export const filterTypePost: Handler<"POST"> = async ( const filter = await getFilter(interaction.user.id, channelId); if (filterType(filter) === to) return flashRedirect( + interaction, panel, `${scopeName(scope, "filter", { capitalize: true })} is already a ${to}`, "warn", @@ -30,9 +31,10 @@ export const filterTypePost: Handler<"POST"> = async ( // 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"); + return flashRedirect(interaction, panel, lead, "success"); return flashRedirect( + interaction, panel, to === "whitelist" ? `${lead}. The people on it were blocked, and are now the only people who can ring you` diff --git a/src/routes/filter/filter.test.ts b/src/routes/filter/filter.test.ts index eac0ed8..de0d393 100644 --- a/src/routes/filter/filter.test.ts +++ b/src/routes/filter/filter.test.ts @@ -22,7 +22,10 @@ vi.mock("@db/filters", async (importOriginal) => ({ resetFilter: vi.fn(), })); -const interaction = { user: { id: "caller" } } as unknown as Interaction; +const interaction = { + user: { id: "caller" }, + isChatInputCommand: () => false, +} as unknown as Interaction; const membersState = (query: string, values?: string[]) => ({ diff --git a/src/routes/index.ts b/src/routes/index.ts index 14178fe..ddb22f9 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -14,6 +14,7 @@ import { catalogGet } from "./help/catalog/get"; import { helpGet } from "./help/get"; import { modeGet } from "./mode/get"; import { modePost } from "./mode/post"; +import { noticeGet } from "./notice/get"; import { pageJumpModal } from "./page-jump/modal"; import { pageJumpPost } from "./page-jump/post"; import { recipientsAutoRingPost } from "./recipients/[scope]/auto-ring/post"; @@ -97,6 +98,8 @@ export const registerRoutes = (router: RingRouter) => { post: rolesByRoleResetPost, }); + router.get("/notice", noticeGet); + router.get("/ring", ringGet); router.post("/ring/users", ringUsersPost); router.post("/ring/user", ringUserPost); diff --git a/src/routes/lib/flash.test.ts b/src/routes/lib/flash.test.ts index f28ce1f..6c96bd8 100644 --- a/src/routes/lib/flash.test.ts +++ b/src/routes/lib/flash.test.ts @@ -1,10 +1,16 @@ +import { Interaction } from "discord.js"; import { expect, test } from "vitest"; -import { flashLine, flashRedirect } from "@routes/lib/flash"; +import { flashLine, flashRedirect, NOTICE } from "@routes/lib/flash"; + +const component = { + isChatInputCommand: () => false, +} as unknown as Interaction; +const command = { isChatInputCommand: () => true } as unknown as Interaction; test("flashRedirect carries the flash text and level as redirect query params", () => { expect( - flashRedirect("/filter/global", "Blocked <@1>", "success"), + flashRedirect(component, "/filter/global", "Blocked <@1>", "success"), ).toStrictEqual({ redirect: "/filter/global", queryParams: { flash: "Blocked <@1>", level: "success" }, @@ -13,13 +19,37 @@ test("flashRedirect carries the flash text and level as redirect query params", test("flashRedirect merges extra params like the page to stay on", () => { expect( - flashRedirect("/signups", "Signed up", "success", { page: "2" }), + flashRedirect(component, "/signups", "Signed up", "success", { + page: "2", + }), ).toStrictEqual({ redirect: "/signups", queryParams: { flash: "Signed up", level: "success", page: "2" }, }); }); +test("a slash-command mutation redirects to the notice with the panel as target", () => { + expect( + flashRedirect(command, "/ring", "Ringed <@1>", "success"), + ).toStrictEqual({ + redirect: NOTICE, + queryParams: { flash: "Ringed <@1>", level: "success", to: "/ring" }, + }); +}); + +test("a slash-command mutation folds extra params into the notice target", () => { + expect( + flashRedirect(command, "/signups", "Signed up", "success", { page: "2" }), + ).toStrictEqual({ + redirect: NOTICE, + queryParams: { + flash: "Signed up", + level: "success", + to: "/signups?page=2", + }, + }); +}); + test("flashLine renders a blockquote with the level icon and a bold lead", () => { expect( flashLine(new URLSearchParams({ flash: "Done", level: "success" })), diff --git a/src/routes/lib/flash.ts b/src/routes/lib/flash.ts index fa15af4..f51dcf0 100644 --- a/src/routes/lib/flash.ts +++ b/src/routes/lib/flash.ts @@ -1,19 +1,37 @@ import { RouteRedirect } from "discord-embed-router"; +import { Interaction } from "discord.js"; export type FlashLevel = "success" | "warn"; -// 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 +// the compact outcome view slash-command mutations land on (routes/notice) +export const NOTICE = "/notice"; + +// the redirect a mutation handler returns so its outcome shows as a notice. +// Component and modal interactions come from a panel, so the notice rides the +// target panel's in-flight query params only — the components the GET builds +// don't carry it, so it clears on the next interaction. Slash commands never +// showed a panel, so they land on the compact notice view instead, with the +// panel (and its extra params) folded into its `to` target export const flashRedirect = ( + interaction: Interaction, redirect: string, flash: string, level: FlashLevel, extraParams: Record = {}, -): RouteRedirect => ({ - redirect, - queryParams: { flash, level, ...extraParams }, -}); +): RouteRedirect => { + if (interaction.isChatInputCommand()) { + const query = new URLSearchParams(extraParams).toString(); + return { + redirect: NOTICE, + queryParams: { + flash, + level, + to: query ? `${redirect}?${query}` : redirect, + }, + }; + } + return { redirect, queryParams: { flash, level, ...extraParams } }; +}; // 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 diff --git a/src/routes/mode/mode.test.ts b/src/routes/mode/mode.test.ts index c85f2a4..12ba257 100644 --- a/src/routes/mode/mode.test.ts +++ b/src/routes/mode/mode.test.ts @@ -10,7 +10,10 @@ vi.mock("@db/users", () => ({ setUserMode: vi.fn(), })); -const interaction = { user: { id: "caller" } } as unknown as Interaction; +const interaction = { + user: { id: "caller" }, + isChatInputCommand: () => false, +} as unknown as Interaction; const state = (query: string) => ({ diff --git a/src/routes/mode/post.ts b/src/routes/mode/post.ts index ab8f331..3a11498 100644 --- a/src/routes/mode/post.ts +++ b/src/routes/mode/post.ts @@ -7,13 +7,28 @@ 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"); + return flashRedirect( + interaction, + 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"); + return flashRedirect( + interaction, + 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"); + return flashRedirect( + interaction, + PATH, + `Mode set to ${target}. ${effect}`, + "success", + ); }; diff --git a/src/routes/notice/get.ts b/src/routes/notice/get.ts new file mode 100644 index 0000000..8b3f91b --- /dev/null +++ b/src/routes/notice/get.ts @@ -0,0 +1,38 @@ +import { RouteButtonBuilder } from "discord-embed-router"; +import { ButtonStyle, EmbedBuilder } from "discord.js"; + +import { row } from "@routes/lib/components"; +import { flashLine } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +const COLOR = "#95a5a6"; + +// panels a slash-command mutation can land on, longest prefix first +const PANEL_NAMES: readonly (readonly [string, string])[] = [ + ["/signups/roles", "Role signups"], + ["/signups", "Signups"], + ["/ring", "Quick ring"], + ["/mode", "Mode"], +]; + +const openLabel = (path: string): string => { + const match = PANEL_NAMES.find(([prefix]) => path.startsWith(prefix)); + return match ? `Open ${match[1]} panel` : "Open panel"; +}; + +export const noticeGet: Handler<"GET"> = (router, interaction, state) => { + const to = state.queryParams.get("to") ?? "/"; + const [path = "/", query = ""] = to.split("?"); + const open = new RouteButtonBuilder(router) + .setLabel(openLabel(path)) + .setStyle(ButtonStyle.Primary) + .setTo(path, { method: "GET", queryParams: query }); + return { + embeds: [ + new EmbedBuilder() + .setColor(COLOR) + .setDescription(flashLine(state.queryParams) ?? "Done"), + ], + components: [row(open)], + }; +}; diff --git a/src/routes/notice/notice.test.ts b/src/routes/notice/notice.test.ts new file mode 100644 index 0000000..f4370ab --- /dev/null +++ b/src/routes/notice/notice.test.ts @@ -0,0 +1,79 @@ +import { EmbedRouter } from "discord-embed-router"; +import { + APIActionRowComponent, + APIButtonComponentWithCustomId, + APIComponentInMessageActionRow, + Client, + EmbedBuilder, + Interaction, +} from "discord.js"; +import { expect, test } from "vitest"; + +import { registerRoutes } from "@routes/index"; +import { RingRouter } from "@routes/types"; + +import { noticeGet } from "./get"; + +const makeRouter = (): RingRouter => { + const router = new EmbedRouter( + new Client({ intents: [] }), + ) as unknown as RingRouter; + registerRoutes(router); + return router; +}; + +const interaction = { user: { id: "caller" } } as unknown as Interaction; + +const state = (query: Record) => + ({ + params: {}, + path: "/notice", + queryParams: new URLSearchParams(query), + timestamp: 0, + globals: { commandIds: new Map() }, + }) as unknown as Parameters[2]; + +const render = async (query: Record) => { + const result = await noticeGet(makeRouter(), interaction, state(query)); + if ("redirect" in result) throw new Error("expected a render"); + const embed = (result.embeds?.[0] as EmbedBuilder).toJSON(); + const row = result + .components?.[0] as APIActionRowComponent; + const button = row.components[0] as APIButtonComponentWithCustomId; + return { embed, button }; +}; + +test("the notice shows the flash line and a button to the target panel", async () => { + const { embed, button } = await render({ + flash: "Ringed <@1>", + level: "success", + to: "/ring", + }); + expect(embed.description).toContain("Ringed <@1>"); + expect(embed.description).toContain("✅"); + expect(button.label).toBe("Open Quick ring panel"); +}); + +test("the notice names the panel for each command-reachable target", async () => { + expect( + (await render({ flash: "x", level: "warn", to: "/mode" })).button.label, + ).toBe("Open Mode panel"); + expect( + (await render({ flash: "x", level: "warn", to: "/signups?page=2" })).button + .label, + ).toBe("Open Signups panel"); + expect( + ( + await render({ + flash: "x", + level: "warn", + to: "/signups/roles/by-role/1", + }) + ).button.label, + ).toBe("Open Role signups panel"); +}); + +test("an unmapped target still gets a working generic button", async () => { + const { button } = await render({ flash: "x", level: "warn", to: "/filter" }); + expect(button.label).toBe("Open panel"); +}); diff --git a/src/routes/page-jump/page-jump.test.ts b/src/routes/page-jump/page-jump.test.ts index 7eec1b7..2ca88c1 100644 --- a/src/routes/page-jump/page-jump.test.ts +++ b/src/routes/page-jump/page-jump.test.ts @@ -3,7 +3,10 @@ import { expect, test } from "vitest"; import { pageJumpPost } from "./post"; -const interaction = { user: { id: "caller" } } as unknown as Interaction; +const interaction = { + user: { id: "caller" }, + isChatInputCommand: () => false, +} as unknown as Interaction; const jump = async (query: string, input: string) => pageJumpPost(undefined as never, interaction, { diff --git a/src/routes/page-jump/post.ts b/src/routes/page-jump/post.ts index cf13d5f..76afc42 100644 --- a/src/routes/page-jump/post.ts +++ b/src/routes/page-jump/post.ts @@ -9,8 +9,14 @@ export const pageJumpPost: Handler<"POST"> = (router, interaction, state) => { 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 flashRedirect( + interaction, + to, + `"${input}" is not a valid page number`, + "warn", + { + page: query.get("page") ?? "0", + }, + ); return { redirect: to, queryParams: { page: String(parsed - 1) } }; }; diff --git a/src/routes/recipients/[scope]/auto-ring/post.ts b/src/routes/recipients/[scope]/auto-ring/post.ts index 438367b..ae28b7d 100644 --- a/src/routes/recipients/[scope]/auto-ring/post.ts +++ b/src/routes/recipients/[scope]/auto-ring/post.ts @@ -18,17 +18,20 @@ export const recipientsAutoRingPost: Handler<"POST"> = async ( const changed = await setAutoRing(interaction.user.id, channelId, enable); if (!changed) return flashRedirect( + interaction, panel, `Auto-ring is already ${enable ? "enabled" : "disabled"} ${scopeSuffix(scope)}`, "warn", ); return enable ? flashRedirect( + interaction, 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( + interaction, panel, `Auto-ring is now disabled ${scopeSuffix(scope)}`, "success", diff --git a/src/routes/recipients/[scope]/members/post.ts b/src/routes/recipients/[scope]/members/post.ts index 081e34e..a106d22 100644 --- a/src/routes/recipients/[scope]/members/post.ts +++ b/src/routes/recipients/[scope]/members/post.ts @@ -73,6 +73,7 @@ export const recipientsMembersPost: Handler<"POST"> = async ( ]; const changed = added.length > 0 || removed.length > 0; return flashRedirect( + interaction, panel, parts.length > 0 ? `${parts.join(". ")} (default ring recipients ${scopeSuffix(scope)})` diff --git a/src/routes/recipients/[scope]/reset/post.ts b/src/routes/recipients/[scope]/reset/post.ts index 316a1cf..89c1911 100644 --- a/src/routes/recipients/[scope]/reset/post.ts +++ b/src/routes/recipients/[scope]/reset/post.ts @@ -14,6 +14,7 @@ export const recipientsResetPost: Handler<"POST"> = async ( const scope = scopeOf(state.params); if (!confirmed(state.fields, "RESET")) return flashRedirect( + interaction, panelPath(scope), "Confirmation text did not match, your recipients were not cleared", "warn", @@ -24,11 +25,13 @@ export const recipientsResetPost: Handler<"POST"> = async ( ); return hadRingees ? flashRedirect( + interaction, panelPath(scope), `Cleared your default ring recipients ${scopeSuffix(scope)}`, "success", ) : flashRedirect( + interaction, panelPath(scope), `You already have no default ring recipients ${scopeSuffix(scope)}`, "warn", diff --git a/src/routes/recipients/recipients.test.ts b/src/routes/recipients/recipients.test.ts index 3656d7c..7e238aa 100644 --- a/src/routes/recipients/recipients.test.ts +++ b/src/routes/recipients/recipients.test.ts @@ -18,7 +18,10 @@ vi.mock("@db/default-ringees", () => ({ resetDefaultRingees: vi.fn(), })); -const interaction = { user: { id: "caller" } } as unknown as Interaction; +const interaction = { + user: { id: "caller" }, + isChatInputCommand: () => false, +} as unknown as Interaction; const autoRingState = (scope: string, query: string) => ({ diff --git a/src/routes/ring/_shared.ts b/src/routes/ring/_shared.ts index 12f5b98..e9d36e0 100644 --- a/src/routes/ring/_shared.ts +++ b/src/routes/ring/_shared.ts @@ -54,9 +54,19 @@ export const ringUserIds = async ( ) => { const channel = voiceChannelOf(interaction); if (!channel) - return flashRedirect(PANEL, noVoiceChannelFlash(interaction), "warn"); + return flashRedirect( + interaction, + PANEL, + noVoiceChannelFlash(interaction), + "warn", + ); if (userIds.length === 0) - return flashRedirect(PANEL, "Nobody was selected to ring", "warn"); + return flashRedirect( + interaction, + PANEL, + "Nobody was selected to ring", + "warn", + ); try { const results = await ring( @@ -66,9 +76,10 @@ export const ringUserIds = async ( userIds, ); const { flash, level } = ringResultsFlash(results); - return flashRedirect(PANEL, flash, level); + return flashRedirect(interaction, PANEL, flash, level); } catch (err) { return flashRedirect( + interaction, PANEL, `Can't ring because ${getErrorMessage(err)}`, "warn", diff --git a/src/routes/ring/default/post.ts b/src/routes/ring/default/post.ts index e89e716..c6a0e2a 100644 --- a/src/routes/ring/default/post.ts +++ b/src/routes/ring/default/post.ts @@ -17,7 +17,12 @@ export const ringDefaultPost: Handler<"POST"> = async ( ) => { const channel = voiceChannelOf(interaction); if (!channel) - return flashRedirect(PANEL, noVoiceChannelFlash(interaction), "warn"); + return flashRedirect( + interaction, + PANEL, + noVoiceChannelFlash(interaction), + "warn", + ); try { const results = await ringDefaultUsers( @@ -26,16 +31,18 @@ export const ringDefaultPost: Handler<"POST"> = async ( "wants you to join", ); const { flash, level } = ringResultsFlash(results); - return flashRedirect(PANEL, flash, level); + return flashRedirect(interaction, PANEL, flash, level); } catch (err) { const message = getErrorMessage(err); return message === "no default users to ring" ? flashRedirect( + interaction, 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( + interaction, PANEL, `Can't ring your default recipients because ${message}`, "warn", diff --git a/src/routes/ring/ring.test.ts b/src/routes/ring/ring.test.ts index b5c388b..0e4f37a 100644 --- a/src/routes/ring/ring.test.ts +++ b/src/routes/ring/ring.test.ts @@ -21,6 +21,7 @@ const makeInteraction = (inVoice: boolean) => user: { id: "caller" }, member: { voice: { channel: inVoice ? voiceChannel : null } }, inGuild: () => true, + isChatInputCommand: () => false, }) as unknown as Interaction; const state = (query: string, values?: string[]) => @@ -100,6 +101,7 @@ test("ringing from a DM flashes the server-only hint and rings nobody", async () user: { id: "caller" }, member: null, inGuild: () => false, + isChatInputCommand: () => false, } as unknown as Interaction; const result = await ringUsersPost( undefined as never, diff --git a/src/routes/signups/members/post.ts b/src/routes/signups/members/post.ts index c4c4edf..c537f5a 100644 --- a/src/routes/signups/members/post.ts +++ b/src/routes/signups/members/post.ts @@ -20,6 +20,7 @@ export const signupsMembersPost: Handler<"POST"> = async ( const guild = interaction.guild; if (!guild) return flashRedirect( + interaction, PANEL, "Signups only work inside a Discord server", "warn", @@ -87,6 +88,7 @@ export const signupsMembersPost: Handler<"POST"> = async ( ]; const changed = added.length > 0 || removed.length > 0; return flashRedirect( + interaction, PANEL, parts.length > 0 ? parts.join(". ") : "No changes to your signups", changed ? "success" : "warn", diff --git a/src/routes/signups/reset/post.ts b/src/routes/signups/reset/post.ts index 6a5ccd5..6f5374c 100644 --- a/src/routes/signups/reset/post.ts +++ b/src/routes/signups/reset/post.ts @@ -14,12 +14,14 @@ export const signupsResetPost: Handler<"POST"> = async ( const guild = interaction.guild; if (!guild) return flashRedirect( + interaction, PANEL, "Signups only work inside a Discord server", "warn", ); if (!confirmed(state.fields, "RESET")) return flashRedirect( + interaction, PANEL, "Confirmation text did not match, your signups were not cleared", "warn", @@ -28,12 +30,18 @@ export const signupsResetPost: Handler<"POST"> = async ( 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"); + return flashRedirect( + interaction, + PANEL, + "You have no signups in this server", + "warn", + ); await Promise.all( signups.map((channelId) => removeVoiceChatUser(channelId, userId)), ); return flashRedirect( + interaction, PANEL, `Cleared your ${signups.length} signup${signups.length > 1 ? "s" : ""} in this server`, "success", diff --git a/src/routes/signups/roles/_shared.ts b/src/routes/signups/roles/_shared.ts index 0975fd5..30a7d10 100644 --- a/src/routes/signups/roles/_shared.ts +++ b/src/routes/signups/roles/_shared.ts @@ -42,18 +42,21 @@ export const roleEditGuard = ( const guild = interaction.guild; if (!guild) return flashRedirect( + interaction, base, "Signups only work inside a Discord server", "warn", ); if (!canManageRoleSignups(interaction)) return flashRedirect( + interaction, 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"); + if (!scope) + return flashRedirect(interaction, base, `Pick a ${noun} first`, "warn"); return { guild, scope }; }; @@ -77,6 +80,7 @@ export const sortChannelIds = (guild: Guild, ids: string[]): string[] => // 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 ({ + interaction, redirect, page, current, @@ -90,6 +94,7 @@ export const commitRoleEdit = async ({ alreadySignedUp, notSignedUp, }: { + interaction: Interaction; redirect: string; page: number; current: string[]; @@ -139,9 +144,15 @@ export const commitRoleEdit = async ({ : removesRequested.length > 0 ? notSignedUp(removesRequested) : "No changes to role signups."; - return flashRedirect(redirect, flash, changed ? "success" : "warn", { - page: String(page), - }); + return flashRedirect( + interaction, + redirect, + flash, + changed ? "success" : "warn", + { + page: String(page), + }, + ); }; // the frame every role-signups view ends in: the caller's content rows, then diff --git a/src/routes/signups/roles/by-channel/reset/post.ts b/src/routes/signups/roles/by-channel/reset/post.ts index 305a105..b6f2911 100644 --- a/src/routes/signups/roles/by-channel/reset/post.ts +++ b/src/routes/signups/roles/by-channel/reset/post.ts @@ -22,6 +22,7 @@ export const rolesByChannelResetPost: Handler<"POST"> = async ( const panel = `${BY_CHANNEL}/${scope}`; if (!confirmed(state.fields, "RESET")) return flashRedirect( + interaction, panel, "Confirmation text did not match, the roles were not cleared", "warn", @@ -30,6 +31,7 @@ export const rolesByChannelResetPost: Handler<"POST"> = async ( const { roleIds } = await getVoiceChatSignups(scope); if (roleIds.length === 0) return flashRedirect( + interaction, panel, `${mentionChannel(scope)} has no roles signed up`, "warn", @@ -39,6 +41,7 @@ export const rolesByChannelResetPost: Handler<"POST"> = async ( roleIds.map((roleId) => removeVoiceChatRole(scope, roleId)), ); return flashRedirect( + interaction, panel, `Cleared the ${roleIds.length} role${roleIds.length > 1 ? "s" : ""} pinged in ${mentionChannel(scope)}`, "success", diff --git a/src/routes/signups/roles/by-channel/roles/post.ts b/src/routes/signups/roles/by-channel/roles/post.ts index fed3a09..0677e9a 100644 --- a/src/routes/signups/roles/by-channel/roles/post.ts +++ b/src/routes/signups/roles/by-channel/roles/post.ts @@ -42,6 +42,7 @@ export const rolesByChannelEditPost: Handler<"POST"> = async ( }); return commitRoleEdit({ + interaction, redirect: `${BY_CHANNEL}/${scope}`, page: parseInt(query.get("page") ?? "0") || 0, current, diff --git a/src/routes/signups/roles/by-role/channels/post.ts b/src/routes/signups/roles/by-role/channels/post.ts index ed4771e..6797d4f 100644 --- a/src/routes/signups/roles/by-role/channels/post.ts +++ b/src/routes/signups/roles/by-role/channels/post.ts @@ -45,6 +45,7 @@ export const rolesByRoleEditPost: Handler<"POST"> = async ( }); return commitRoleEdit({ + interaction, redirect: `${BY_ROLE}/${scope}`, page: parseInt(query.get("page") ?? "0") || 0, current, diff --git a/src/routes/signups/roles/by-role/reset/post.ts b/src/routes/signups/roles/by-role/reset/post.ts index f032ade..2d746b0 100644 --- a/src/routes/signups/roles/by-role/reset/post.ts +++ b/src/routes/signups/roles/by-role/reset/post.ts @@ -23,6 +23,7 @@ export const rolesByRoleResetPost: Handler<"POST"> = async ( const panel = `${BY_ROLE}/${scope}`; if (!confirmed(state.fields, "RESET")) return flashRedirect( + interaction, panel, "Confirmation text did not match, the signups were not cleared", "warn", @@ -35,6 +36,7 @@ export const rolesByRoleResetPost: Handler<"POST"> = async ( .map((mapping) => mapping.channelId); if (channelIds.length === 0) return flashRedirect( + interaction, panel, `${mentionRole(scope)} is not signed up for any voice channels`, "warn", @@ -44,6 +46,7 @@ export const rolesByRoleResetPost: Handler<"POST"> = async ( channelIds.map((channelId) => removeVoiceChatRole(channelId, scope)), ); return flashRedirect( + interaction, 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 43db730..81cbd11 100644 --- a/src/routes/signups/signups.test.ts +++ b/src/routes/signups/signups.test.ts @@ -29,6 +29,7 @@ vi.mock("@db/voice-chats", () => ({ const makeInteraction = (managesRoles: boolean) => ({ user: { id: "caller" }, + isChatInputCommand: () => false, memberPermissions: new PermissionsBitField( managesRoles ? PermissionsBitField.Flags.ManageRoles : 0n, ), From 1a8a0e2304369ca89529443e325dec97c2ba9c97 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:31:53 -0400 Subject: [PATCH 2/9] Render the notice view's flash unquoted Co-Authored-By: Claude Fable 5 --- src/routes/lib/flash.ts | 21 +++++++++++++++------ src/routes/notice/get.ts | 4 ++-- src/routes/notice/notice.test.ts | 3 +++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/routes/lib/flash.ts b/src/routes/lib/flash.ts index f51dcf0..2037740 100644 --- a/src/routes/lib/flash.ts +++ b/src/routes/lib/flash.ts @@ -41,16 +41,25 @@ const boldLead = (text: string): string => { return `**${text.slice(0, periodIndex + 1)}**${text.slice(periodIndex + 1)}`; }; -// the notice a panel renders at the bottom of its embed, as a markdown -// blockquote so it separates from the body; the level picks the icon -export const flashLine = (queryParams: URLSearchParams): string | null => { +// the flash as plain lines with the level icon and a bold lead — what the +// notice view shows as its whole body +export const flashText = (queryParams: URLSearchParams): string | null => { const flash = queryParams.get("flash"); if (!flash) return null; const icon = queryParams.get("level") === "warn" ? "⚠️" : "✅"; - // 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 + // bold only the first line's lead: Discord bold can't span newlines, so + // the markers must open and close on one line const [first = "", ...rest] = flash.split("\n"); - return [`${icon} ${boldLead(first)}`, ...rest] + return [`${icon} ${boldLead(first)}`, ...rest].join("\n"); +}; + +// the notice a panel renders at the bottom of its embed, as a markdown +// blockquote so it separates from the body +export const flashLine = (queryParams: URLSearchParams): string | null => { + const text = flashText(queryParams); + if (text === null) return null; + return text + .split("\n") .map((line) => `> ${line}`) .join("\n"); }; diff --git a/src/routes/notice/get.ts b/src/routes/notice/get.ts index 8b3f91b..43dd52c 100644 --- a/src/routes/notice/get.ts +++ b/src/routes/notice/get.ts @@ -2,7 +2,7 @@ import { RouteButtonBuilder } from "discord-embed-router"; import { ButtonStyle, EmbedBuilder } from "discord.js"; import { row } from "@routes/lib/components"; -import { flashLine } from "@routes/lib/flash"; +import { flashText } from "@routes/lib/flash"; import { Handler } from "@routes/types"; const COLOR = "#95a5a6"; @@ -31,7 +31,7 @@ export const noticeGet: Handler<"GET"> = (router, interaction, state) => { embeds: [ new EmbedBuilder() .setColor(COLOR) - .setDescription(flashLine(state.queryParams) ?? "Done"), + .setDescription(flashText(state.queryParams) ?? "Done"), ], components: [row(open)], }; diff --git a/src/routes/notice/notice.test.ts b/src/routes/notice/notice.test.ts index f4370ab..4ee7557 100644 --- a/src/routes/notice/notice.test.ts +++ b/src/routes/notice/notice.test.ts @@ -51,6 +51,9 @@ test("the notice shows the flash line and a button to the target panel", async ( }); expect(embed.description).toContain("Ringed <@1>"); expect(embed.description).toContain("✅"); + // the flash is the whole message here, not a footnote on a panel, so it + // renders unquoted + expect(embed.description).not.toMatch(/^> /m); expect(button.label).toBe("Open Quick ring panel"); }); From 225f76877cd17cbbfa123146c3f76ad2c900821f Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:33:35 -0400 Subject: [PATCH 3/9] Use a secondary Open panel button on the notice view Co-Authored-By: Claude Fable 5 --- src/routes/notice/get.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/notice/get.ts b/src/routes/notice/get.ts index 43dd52c..fade781 100644 --- a/src/routes/notice/get.ts +++ b/src/routes/notice/get.ts @@ -25,7 +25,7 @@ export const noticeGet: Handler<"GET"> = (router, interaction, state) => { const [path = "/", query = ""] = to.split("?"); const open = new RouteButtonBuilder(router) .setLabel(openLabel(path)) - .setStyle(ButtonStyle.Primary) + .setStyle(ButtonStyle.Secondary) .setTo(path, { method: "GET", queryParams: query }); return { embeds: [ From 4fe7f3af91898d195786590c9eed38743d2c0988 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:39:53 -0400 Subject: [PATCH 4/9] Point the no-defaults notice button at the default-ringees panel Co-Authored-By: Claude Fable 5 --- src/routes/notice/get.ts | 1 + src/routes/notice/notice.test.ts | 9 +++++++++ src/routes/ring/default/post.ts | 6 ++++-- src/routes/ring/ring.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/routes/notice/get.ts b/src/routes/notice/get.ts index fade781..bd78d80 100644 --- a/src/routes/notice/get.ts +++ b/src/routes/notice/get.ts @@ -11,6 +11,7 @@ const COLOR = "#95a5a6"; const PANEL_NAMES: readonly (readonly [string, string])[] = [ ["/signups/roles", "Role signups"], ["/signups", "Signups"], + ["/recipients", "Default ringees"], ["/ring", "Quick ring"], ["/mode", "Mode"], ]; diff --git a/src/routes/notice/notice.test.ts b/src/routes/notice/notice.test.ts index 4ee7557..dd67f24 100644 --- a/src/routes/notice/notice.test.ts +++ b/src/routes/notice/notice.test.ts @@ -76,6 +76,15 @@ test("the notice names the panel for each command-reachable target", async () => ).toBe("Open Role signups panel"); }); +test("the notice names the default ringees panel", async () => { + const { button } = await render({ + flash: "x", + level: "warn", + to: "/recipients/global", + }); + expect(button.label).toBe("Open Default ringees panel"); +}); + test("an unmapped target still gets a working generic button", async () => { const { button } = await render({ flash: "x", level: "warn", to: "/filter" }); expect(button.label).toBe("Open panel"); diff --git a/src/routes/ring/default/post.ts b/src/routes/ring/default/post.ts index c6a0e2a..b2331bb 100644 --- a/src/routes/ring/default/post.ts +++ b/src/routes/ring/default/post.ts @@ -35,9 +35,11 @@ export const ringDefaultPost: Handler<"POST"> = async ( } catch (err) { const message = getErrorMessage(err); return message === "no default users to ring" - ? flashRedirect( + ? // from a slash command the notice's button leads to the default-ringees + // panel, where the missing recipients get added + flashRedirect( interaction, - PANEL, + interaction.isChatInputCommand() ? "/recipients/global" : 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", ) diff --git a/src/routes/ring/ring.test.ts b/src/routes/ring/ring.test.ts index 0e4f37a..e2c7893 100644 --- a/src/routes/ring/ring.test.ts +++ b/src/routes/ring/ring.test.ts @@ -66,6 +66,29 @@ test("ringing defaults rings the saved list and returns to the Quick ring panel" expect(result.redirect).toBe("/ring"); }); +test("ringing defaults with none saved points the notice at the default ringees panel", async () => { + vi.mocked(ringDefaultUsers).mockRejectedValue( + new Error("no default users to ring"), + ); + + const command = { + ...(makeInteraction(true) as object), + isChatInputCommand: () => true, + } as unknown as Interaction; + const result = await ringDefaultPost( + undefined as never, + command, + defaultState(), + ); + + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(result.redirect).toBe("/notice"); + expect(flashParams.get("to")).toBe("/recipients/global"); + expect(flashParams.get("level")).toBe("warn"); +}); + test("ringing defaults while not in a voice channel warns without ringing", async () => { const result = await ringDefaultPost( undefined as never, From b94b200240f131d9e13d6117cac44cee9df79afc Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:44:10 -0400 Subject: [PATCH 5/9] Color the notice embed by outcome level Co-Authored-By: Claude Fable 5 --- src/routes/notice/get.ts | 7 +++++-- src/routes/notice/notice.test.ts | 6 ++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/routes/notice/get.ts b/src/routes/notice/get.ts index bd78d80..d02fedc 100644 --- a/src/routes/notice/get.ts +++ b/src/routes/notice/get.ts @@ -5,7 +5,8 @@ import { row } from "@routes/lib/components"; import { flashText } from "@routes/lib/flash"; import { Handler } from "@routes/types"; -const COLOR = "#95a5a6"; +// Discord's green/yellow, matching the flash line's ✅/⚠️ icon +const COLORS = { success: "#57f287", warn: "#fee75c" } as const; // panels a slash-command mutation can land on, longest prefix first const PANEL_NAMES: readonly (readonly [string, string])[] = [ @@ -31,7 +32,9 @@ export const noticeGet: Handler<"GET"> = (router, interaction, state) => { return { embeds: [ new EmbedBuilder() - .setColor(COLOR) + .setColor( + COLORS[state.queryParams.get("level") === "warn" ? "warn" : "success"], + ) .setDescription(flashText(state.queryParams) ?? "Done"), ], components: [row(open)], diff --git a/src/routes/notice/notice.test.ts b/src/routes/notice/notice.test.ts index dd67f24..bdc0a38 100644 --- a/src/routes/notice/notice.test.ts +++ b/src/routes/notice/notice.test.ts @@ -76,6 +76,12 @@ test("the notice names the panel for each command-reachable target", async () => ).toBe("Open Role signups panel"); }); +test("the notice embed color tracks the outcome level", async () => { + const success = await render({ flash: "x", level: "success", to: "/ring" }); + const warn = await render({ flash: "x", level: "warn", to: "/ring" }); + expect(success.embed.color).not.toBe(warn.embed.color); +}); + test("the notice names the default ringees panel", async () => { const { button } = await render({ flash: "x", From c532687a632d397ebc5ccb1e290657ea053b0fcb Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:51:10 -0400 Subject: [PATCH 6/9] Name the global filter panel on notices from the filter commands Co-Authored-By: Claude Fable 5 --- src/routes/notice/get.ts | 5 +++-- src/routes/notice/notice.test.ts | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/routes/notice/get.ts b/src/routes/notice/get.ts index d02fedc..69e889c 100644 --- a/src/routes/notice/get.ts +++ b/src/routes/notice/get.ts @@ -9,13 +9,14 @@ import { Handler } from "@routes/types"; const COLORS = { success: "#57f287", warn: "#fee75c" } as const; // panels a slash-command mutation can land on, longest prefix first -const PANEL_NAMES: readonly (readonly [string, string])[] = [ +const PANEL_NAMES = [ ["/signups/roles", "Role signups"], ["/signups", "Signups"], ["/recipients", "Default ringees"], ["/ring", "Quick ring"], ["/mode", "Mode"], -]; + ["/filter/global", "Global filter"], +] as const; const openLabel = (path: string): string => { const match = PANEL_NAMES.find(([prefix]) => path.startsWith(prefix)); diff --git a/src/routes/notice/notice.test.ts b/src/routes/notice/notice.test.ts index bdc0a38..9cc6ac3 100644 --- a/src/routes/notice/notice.test.ts +++ b/src/routes/notice/notice.test.ts @@ -91,7 +91,20 @@ test("the notice names the default ringees panel", async () => { expect(button.label).toBe("Open Default ringees panel"); }); +test("the notice names the global filter panel", async () => { + const { button } = await render({ + flash: "x", + level: "warn", + to: "/filter/global", + }); + expect(button.label).toBe("Open Global filter panel"); +}); + test("an unmapped target still gets a working generic button", async () => { - const { button } = await render({ flash: "x", level: "warn", to: "/filter" }); + const { button } = await render({ + flash: "x", + level: "warn", + to: "/delete-data", + }); expect(button.label).toBe("Open panel"); }); From d7e81706e3f218e1df32eaa547eadfd7f9b663d1 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:51:37 -0400 Subject: [PATCH 7/9] Inline the notice color ternary Co-Authored-By: Claude Fable 5 --- src/routes/notice/get.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/routes/notice/get.ts b/src/routes/notice/get.ts index 69e889c..5bcac44 100644 --- a/src/routes/notice/get.ts +++ b/src/routes/notice/get.ts @@ -5,17 +5,14 @@ import { row } from "@routes/lib/components"; import { flashText } from "@routes/lib/flash"; import { Handler } from "@routes/types"; -// Discord's green/yellow, matching the flash line's ✅/⚠️ icon -const COLORS = { success: "#57f287", warn: "#fee75c" } as const; - // panels a slash-command mutation can land on, longest prefix first const PANEL_NAMES = [ + ["/filter/global", "Global filter"], ["/signups/roles", "Role signups"], ["/signups", "Signups"], ["/recipients", "Default ringees"], ["/ring", "Quick ring"], ["/mode", "Mode"], - ["/filter/global", "Global filter"], ] as const; const openLabel = (path: string): string => { @@ -33,8 +30,9 @@ export const noticeGet: Handler<"GET"> = (router, interaction, state) => { return { embeds: [ new EmbedBuilder() + // Discord's yellow/green, matching the flash line's ⚠️/✅ icon .setColor( - COLORS[state.queryParams.get("level") === "warn" ? "warn" : "success"], + state.queryParams.get("level") === "warn" ? "#fee75c" : "#57f287", ) .setDescription(flashText(state.queryParams) ?? "Done"), ], From 13a29cc2bbae340db1a0bed070d92f1270a44847 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:21:18 -0400 Subject: [PATCH 8/9] Reset the Quick ring user select after each render Co-Authored-By: Claude Fable 5 --- src/routes/ring/get.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index ba2369c..9442d52 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -54,7 +54,11 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { .setMinValues(1) .setMaxValues(SELECT_MAX_VALUES) .setPlaceholder("Select up to 25 people to ring") - .setPattern(`${PANEL}/users`, { method: "POST" }), + // the per-render key resets Discord's kept selection after a ring + .setPattern(`${PANEL}/users`, { + method: "POST", + key: state.timestamp.toString(36), + }), ) .toJSON(), row( From 1f7409ebf9ffbdfaf990debd9a6533dea59a9169 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:27:56 -0400 Subject: [PATCH 9/9] Mention the ring command clickably in the no-voice-channel hints Co-Authored-By: Claude Fable 5 --- src/routes/ring/_shared.ts | 20 +++++++++++++------- src/routes/ring/default/post.ts | 2 +- src/routes/ring/get.ts | 4 +++- src/routes/ring/ring.test.ts | 18 ++++++++++++++++++ src/routes/ring/user/post.ts | 2 +- src/routes/ring/users/post.ts | 2 +- 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/routes/ring/_shared.ts b/src/routes/ring/_shared.ts index e9d36e0..11a4193 100644 --- a/src/routes/ring/_shared.ts +++ b/src/routes/ring/_shared.ts @@ -8,12 +8,10 @@ import { UserRingResult, } from "@main/ring"; import { flashRedirect } from "@routes/lib/flash"; +import { commandMention } from "@routes/lib/mentions"; +import { Globals } from "@routes/types"; 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 @@ -26,8 +24,15 @@ export const voiceChannelOf = ( // 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 noVoiceChannelFlash = ( + interaction: Interaction, + globals: Globals | undefined, +): string => { + const ringMention = commandMention(globals, "ring"); + return interaction.inGuild() + ? `RingVC needs to know which voice channel to ring people in. Join one, then run ${ringMention} again.` + : `RingVC can only ring people from within a Discord server. Run ${ringMention} in a server instead.`; +}; export const ringResultsFlash = (results: UserRingResult[]) => { const ringed = results @@ -50,6 +55,7 @@ export const ringResultsFlash = (results: UserRingResult[]) => { export const ringUserIds = async ( interaction: Interaction, + globals: Globals | undefined, userIds: string[], ) => { const channel = voiceChannelOf(interaction); @@ -57,7 +63,7 @@ export const ringUserIds = async ( return flashRedirect( interaction, PANEL, - noVoiceChannelFlash(interaction), + noVoiceChannelFlash(interaction, globals), "warn", ); if (userIds.length === 0) diff --git a/src/routes/ring/default/post.ts b/src/routes/ring/default/post.ts index b2331bb..fc7c6e5 100644 --- a/src/routes/ring/default/post.ts +++ b/src/routes/ring/default/post.ts @@ -20,7 +20,7 @@ export const ringDefaultPost: Handler<"POST"> = async ( return flashRedirect( interaction, PANEL, - noVoiceChannelFlash(interaction), + noVoiceChannelFlash(interaction, state.globals), "warn", ); diff --git a/src/routes/ring/get.ts b/src/routes/ring/get.ts index 9442d52..9b63dc5 100644 --- a/src/routes/ring/get.ts +++ b/src/routes/ring/get.ts @@ -28,7 +28,9 @@ export const ringGet: Handler<"GET"> = async (router, interaction, state) => { new EmbedBuilder() .setColor(COLOR) .setTitle("📣 Quick ring") - .setDescription(`⚠️ ${noVoiceChannelFlash(interaction)}`), + .setDescription( + `⚠️ ${noVoiceChannelFlash(interaction, state.globals)}`, + ), ], components: [ringViews, navBar(router, interaction)], }; diff --git a/src/routes/ring/ring.test.ts b/src/routes/ring/ring.test.ts index e2c7893..3290c69 100644 --- a/src/routes/ring/ring.test.ts +++ b/src/routes/ring/ring.test.ts @@ -89,6 +89,24 @@ test("ringing defaults with none saved points the notice at the default ringees expect(flashParams.get("level")).toBe("warn"); }); +test("the join-a-voice-channel hint mentions the ring command clickably", async () => { + const withCommandIds = { + ...(state("", ["9"]) as object), + globals: { commandIds: new Map([["ring", "123"]]) }, + } as unknown as Parameters[2]; + + const result = await ringUsersPost( + undefined as never, + makeInteraction(false), + withCommandIds, + ); + + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("flash")).toContain(""); +}); + test("ringing defaults while not in a voice channel warns without ringing", async () => { const result = await ringDefaultPost( undefined as never, diff --git a/src/routes/ring/user/post.ts b/src/routes/ring/user/post.ts index af3ea9e..0b1ad7d 100644 --- a/src/routes/ring/user/post.ts +++ b/src/routes/ring/user/post.ts @@ -9,5 +9,5 @@ export const ringUserPost: Handler<"POST"> = async ( state, ) => { const userId = state.queryParams.get("id"); - return ringUserIds(interaction, userId ? [userId] : []); + return ringUserIds(interaction, state.globals, userId ? [userId] : []); }; diff --git a/src/routes/ring/users/post.ts b/src/routes/ring/users/post.ts index 69dbc31..61807ae 100644 --- a/src/routes/ring/users/post.ts +++ b/src/routes/ring/users/post.ts @@ -6,4 +6,4 @@ export const ringUsersPost: Handler<"POST"> = async ( router, interaction, state, -) => ringUserIds(interaction, state.values ?? []); +) => ringUserIds(interaction, state.globals, state.values ?? []);