Skip to content
5 changes: 4 additions & 1 deletion src/routes/delete-data/delete-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
({
Expand Down
2 changes: 2 additions & 0 deletions src/routes/delete-data/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ export const deleteDataPost: Handler<"POST"> = async (
) => {
if (!confirmed(state.fields, CONFIRMATION))
return flashRedirect(
interaction,
PANEL,
"Confirmation text did not match, nothing was deleted",
"warn",
);

const hadData = await deleteAllUserData(interaction.user.id);
return flashRedirect(
interaction,
PANEL,
hadData
? "All your data has been deleted"
Expand Down
14 changes: 11 additions & 3 deletions src/routes/filter/[scope]/members/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
},
);
};
3 changes: 3 additions & 0 deletions src/routes/filter/[scope]/reset/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/routes/filter/[scope]/type/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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`
Expand Down
5 changes: 4 additions & 1 deletion src/routes/filter/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) =>
({
Expand Down
3 changes: 3 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 33 additions & 3 deletions src/routes/lib/flash.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
Expand All @@ -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" })),
Expand Down
53 changes: 40 additions & 13 deletions src/routes/lib/flash.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {},
): 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
Expand All @@ -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");
};
Expand Down
5 changes: 4 additions & 1 deletion src/routes/mode/mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
({
Expand Down
21 changes: 18 additions & 3 deletions src/routes/mode/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
};
41 changes: 41 additions & 0 deletions src/routes/notice/get.ts
Original file line number Diff line number Diff line change
@@ -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)],
};
};
Loading
Loading