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..2037740 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 @@ -23,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/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..5bcac44 --- /dev/null +++ b/src/routes/notice/get.ts @@ -0,0 +1,41 @@ +import { RouteButtonBuilder } from "discord-embed-router"; +import { ButtonStyle, EmbedBuilder } from "discord.js"; + +import { row } from "@routes/lib/components"; +import { flashText } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +// 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"], +] as const; + +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.Secondary) + .setTo(path, { method: "GET", queryParams: query }); + return { + embeds: [ + new EmbedBuilder() + // Discord's yellow/green, matching the flash line's ⚠️/✅ icon + .setColor( + state.queryParams.get("level") === "warn" ? "#fee75c" : "#57f287", + ) + .setDescription(flashText(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..9cc6ac3 --- /dev/null +++ b/src/routes/notice/notice.test.ts @@ -0,0 +1,110 @@ +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("✅"); + // 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"); +}); + +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("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", + level: "warn", + to: "/recipients/global", + }); + 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: "/delete-data", + }); + 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..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,13 +55,24 @@ export const ringResultsFlash = (results: UserRingResult[]) => { export const ringUserIds = async ( interaction: Interaction, + globals: Globals | undefined, userIds: string[], ) => { const channel = voiceChannelOf(interaction); if (!channel) - return flashRedirect(PANEL, noVoiceChannelFlash(interaction), "warn"); + return flashRedirect( + interaction, + PANEL, + noVoiceChannelFlash(interaction, globals), + "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 +82,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..fc7c6e5 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, state.globals), + "warn", + ); try { const results = await ringDefaultUsers( @@ -26,16 +31,20 @@ 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( - PANEL, + ? // from a slash command the notice's button leads to the default-ringees + // panel, where the missing recipients get added + flashRedirect( + interaction, + 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", ) : flashRedirect( + interaction, 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 ba2369c..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)], }; @@ -54,7 +56,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( diff --git a/src/routes/ring/ring.test.ts b/src/routes/ring/ring.test.ts index b5c388b..3290c69 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[]) => @@ -65,6 +66,47 @@ 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("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, @@ -100,6 +142,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/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 ?? []); 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, ),