diff --git a/.dockerignore b/.dockerignore index 7801848..5f37fd6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,4 +22,4 @@ icon.svg LICENSE Privacy.md README.md -Terms&Conditions.md \ No newline at end of file +TermsAndConditions.md \ No newline at end of file diff --git a/Privacy.md b/Privacy.md index ea9555b..0819988 100644 --- a/Privacy.md +++ b/Privacy.md @@ -8,6 +8,7 @@ - User IDs of users who use block or filter commands to set up a filter of who can ring them, and the User IDs of the people that they block - Voice Channel IDs and User IDs when a user is signed up for a Voice Channel +- Feedback submitted through the feedback form (the text and a timestamp only, never who submitted it) ## Temporary Data diff --git a/Terms&Conditions.md b/TermsAndConditions.md similarity index 100% rename from Terms&Conditions.md rename to TermsAndConditions.md diff --git a/src/commands/ringvc.ts b/src/commands/about.ts similarity index 66% rename from src/commands/ringvc.ts rename to src/commands/about.ts index 0a84c74..da444d2 100644 --- a/src/commands/ringvc.ts +++ b/src/commands/about.ts @@ -6,12 +6,12 @@ import { import { RingRouter } from "@routes/types"; -export const ringvc = { +export const about = { data: new SlashCommandBuilder() - .setName("ringvc") - .setDescription("Open the RingVC home panel"), + .setName("about") + .setDescription("Project links, policies, and feedback"), async execute(router: RingRouter, interaction: ChatInputCommandInteraction) { - await router.dispatch(interaction, "/", { + await router.dispatch(interaction, "/about", { flags: [MessageFlags.Ephemeral], }); }, diff --git a/src/commands/commandNames.ts b/src/commands/commandNames.ts index 856a275..7b41641 100644 --- a/src/commands/commandNames.ts +++ b/src/commands/commandNames.ts @@ -1,7 +1,7 @@ export const commandNamesList = [ - "ringvc", "help", "catalog", + "about", "delete_data", "ring", "ring_defaults", diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 36c3af6..5048f8b 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,5 +1,6 @@ import { ChatInputCommandInteraction, SharedSlashCommand } from "discord.js"; +import { about } from "@commands/about"; import { catalog } from "@commands/catalog"; import { defaultRingRecipients } from "@commands/defaultRingRecipient"; import { deleteData } from "@commands/deleteData"; @@ -13,7 +14,6 @@ import { mode } from "@commands/mode"; import { quit } from "@commands/quit"; import { ring } from "@commands/ring"; import { ringDefaults } from "@commands/ringDefaults"; -import { ringvc } from "@commands/ringvc"; import { signup } from "@commands/signup"; import { signuprole } from "@commands/signuprole"; import { unsignup } from "@commands/unsignup"; @@ -31,9 +31,9 @@ export type CommandImplementation = { }; export const commands: CommandImplementation[] = [ - ringvc, help, catalog, + about, deleteData, ring, ringDefaults, diff --git a/src/main/db/database.types.ts b/src/main/db/database.types.ts index 050ab38..62d4578 100644 --- a/src/main/db/database.types.ts +++ b/src/main/db/database.types.ts @@ -107,6 +107,24 @@ export type Database = { }; Relationships: []; }; + feedback: { + Row: { + content: string; + created_at: string; + id: number; + }; + Insert: { + content: string; + created_at?: string; + id?: never; + }; + Update: { + content?: string; + created_at?: string; + id?: never; + }; + Relationships: []; + }; filter_entries: { Row: { channel_id: string | null; diff --git a/src/main/db/feedback.ts b/src/main/db/feedback.ts new file mode 100644 index 0000000..4325b84 --- /dev/null +++ b/src/main/db/feedback.ts @@ -0,0 +1,5 @@ +import { db, throwOnError } from "./client"; + +export const submitFeedback = async (content: string): Promise => { + throwOnError(await db.from("feedback").insert({ content })); +}; diff --git a/src/routes/_shared.ts b/src/routes/_shared.ts deleted file mode 100644 index ac30fab..0000000 --- a/src/routes/_shared.ts +++ /dev/null @@ -1 +0,0 @@ -export const COLOR = "#a082c3"; diff --git a/src/routes/about/_shared.ts b/src/routes/about/_shared.ts new file mode 100644 index 0000000..5d5ba10 --- /dev/null +++ b/src/routes/about/_shared.ts @@ -0,0 +1,2 @@ +export const PANEL = "/about"; +export const FEEDBACK = "/about/feedback"; diff --git a/src/routes/about/about.test.ts b/src/routes/about/about.test.ts new file mode 100644 index 0000000..06c2068 --- /dev/null +++ b/src/routes/about/about.test.ts @@ -0,0 +1,57 @@ +import { Interaction } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { submitFeedback } from "@db/feedback"; + +import { aboutFeedbackPost } from "./feedback/post"; + +vi.mock("@db/feedback", () => ({ + submitFeedback: vi.fn(), +})); + +const interaction = { + user: { id: "caller" }, + isChatInputCommand: () => false, +} as unknown as Interaction; + +const state = (content: string) => + ({ + params: {}, + path: "/about/feedback", + queryParams: new URLSearchParams(), + timestamp: 0, + fields: { getTextInputValue: () => content }, + }) as unknown as Parameters[2]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("submitting feedback stores the trimmed text", async () => { + const result = await aboutFeedbackPost( + undefined as never, + interaction, + state(" great bot! "), + ); + + expect(submitFeedback).toHaveBeenCalledExactlyOnceWith("great bot!"); + expect(result.redirect).toBe("/about"); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("success"); +}); + +test("whitespace-only feedback stores nothing", async () => { + const result = await aboutFeedbackPost( + undefined as never, + interaction, + state(" "), + ); + + expect(submitFeedback).not.toHaveBeenCalled(); + const flashParams = new URLSearchParams( + result.queryParams as Record, + ); + expect(flashParams.get("level")).toBe("warn"); +}); diff --git a/src/routes/about/feedback/modal.ts b/src/routes/about/feedback/modal.ts new file mode 100644 index 0000000..ecdd94d --- /dev/null +++ b/src/routes/about/feedback/modal.ts @@ -0,0 +1,23 @@ +import { RouteModalBuilder } from "discord-embed-router"; +import { LabelBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; + +import { Handler } from "@routes/types"; + +import { FEEDBACK } from "../_shared"; + +export const aboutFeedbackModal: Handler<"MODAL"> = (router) => + new RouteModalBuilder(router) + .setTo(FEEDBACK, { method: "POST" }) + .setTitle("Give feedback") + .addLabelComponents( + new LabelBuilder() + .setLabel("Your feedback") + .setDescription("Submissions are anonymous.") + .setTextInputComponent( + new TextInputBuilder() + .setCustomId("content") + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setPlaceholder("Bug reports, feature ideas, anything"), + ), + ); diff --git a/src/routes/about/feedback/post.ts b/src/routes/about/feedback/post.ts new file mode 100644 index 0000000..8d8d540 --- /dev/null +++ b/src/routes/about/feedback/post.ts @@ -0,0 +1,28 @@ +import { submitFeedback } from "@db/feedback"; +import { flashRedirect } from "@routes/lib/flash"; +import { Handler } from "@routes/types"; + +import { PANEL } from "../_shared"; + +export const aboutFeedbackPost: Handler<"POST"> = async ( + router, + interaction, + state, +) => { + const content = (state.fields?.getTextInputValue("content") ?? "").trim(); + if (!content) + return flashRedirect( + interaction, + PANEL, + "Feedback was empty, nothing was sent", + "warn", + ); + + await submitFeedback(content); + return flashRedirect( + interaction, + PANEL, + "Feedback sent. Thank you!", + "success", + ); +}; diff --git a/src/routes/about/get.ts b/src/routes/about/get.ts index 5e3e3a1..9231bcb 100644 --- a/src/routes/about/get.ts +++ b/src/routes/about/get.ts @@ -1,13 +1,19 @@ +import { RouteButtonBuilder } from "discord-embed-router"; import { ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; import { navBar, row } from "@routes/lib/components"; import { withFlash } from "@routes/lib/flash"; import { Handler } from "@routes/types"; +import { FEEDBACK } from "./_shared"; + const COLOR = "#5865f2"; const GITHUB_URL = "https://github.com/altrup/RingVC"; const SUPPORT_URL = "https://discord.gg/bxBePEnndq"; +const PRIVACY_URL = "https://github.com/altrup/RingVC/blob/main/Privacy.md"; +const TERMS_URL = + "https://github.com/altrup/RingVC/blob/main/TermsAndConditions.md"; export const aboutGet: Handler<"GET"> = (router, interaction, state) => { return { @@ -18,7 +24,7 @@ export const aboutGet: Handler<"GET"> = (router, interaction, state) => { .setDescription( withFlash( state.queryParams, - "RingVC is free and open source. Star it on GitHub, or join the support server for help and updates.", + "RingVC replicates group-chat voice calls in Discord servers: sign up for a voice channel and get pinged when someone starts a call there.\n\nIt's free and open source. Star it on GitHub, or join the support server for help and updates.\n\nHave a bug report or feature idea? Give anonymous feedback below.", ), ), ], @@ -32,6 +38,20 @@ export const aboutGet: Handler<"GET"> = (router, interaction, state) => { .setLabel("Support Server") .setStyle(ButtonStyle.Link) .setURL(SUPPORT_URL), + new RouteButtonBuilder(router) + .setLabel("Give feedback") + .setStyle(ButtonStyle.Primary) + .setTo(FEEDBACK, { method: "MODAL" }), + ), + row( + new ButtonBuilder() + .setLabel("Privacy Policy") + .setStyle(ButtonStyle.Link) + .setURL(PRIVACY_URL), + new ButtonBuilder() + .setLabel("Terms & Conditions") + .setStyle(ButtonStyle.Link) + .setURL(TERMS_URL), ), navBar(router, interaction), ], diff --git a/src/routes/get.ts b/src/routes/get.ts deleted file mode 100644 index df9bc33..0000000 --- a/src/routes/get.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { EmbedBuilder } from "discord.js"; - -import { navBar } from "@routes/lib/components"; -import { emojiIconURL, RINGVC_EMOJI_ID } from "@routes/lib/emoji"; -import { withFlash } from "@routes/lib/flash"; -import { Handler } from "@routes/types"; - -import { COLOR } from "./_shared"; - -export const homeGet: Handler<"GET"> = (router, interaction, state) => { - // the branded author line (icon + "RingVC") is the panel's header, so - // there is no separate title to duplicate it - const embed = new EmbedBuilder() - .setColor(COLOR) - .setAuthor({ name: "RingVC", iconURL: emojiIconURL(RINGVC_EMOJI_ID) }) - .setDescription( - withFlash( - state.queryParams, - "RingVC replicates group-chat voice calls in Discord servers: sign up for a voice channel and get pinged when someone starts a call there.", - ), - ); - - return { - embeds: [embed], - components: [navBar(router, interaction)], - }; -}; diff --git a/src/routes/help/catalog/get.ts b/src/routes/help/catalog/get.ts index 836dec0..baf42c4 100644 --- a/src/routes/help/catalog/get.ts +++ b/src/routes/help/catalog/get.ts @@ -52,9 +52,9 @@ export const catalogGet: Handler<"GET"> = (router, interaction, state) => { { title: "Panels", entries: [ - ["ringvc", "the home panel"], ["help", "getting started"], ["catalog", "this command catalog"], + ["about", "project links, policies, and feedback"], ["signup", "your signups panel (bare, outside a voice channel)"], ["filter", "your filter panel"], ["default_ring_recipients", "ring recipients and auto-ring panel"], diff --git a/src/routes/index.ts b/src/routes/index.ts index ddb22f9..88d3732 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,5 +1,7 @@ import { RingRouter } from "@routes/types"; +import { aboutFeedbackModal } from "./about/feedback/modal"; +import { aboutFeedbackPost } from "./about/feedback/post"; import { aboutGet } from "./about/get"; import { deleteDataGet } from "./delete-data/get"; import { deleteDataModal } from "./delete-data/modal"; @@ -9,7 +11,6 @@ import { filterMembersPost } from "./filter/[scope]/members/post"; import { filterResetModal } from "./filter/[scope]/reset/modal"; import { filterResetPost } from "./filter/[scope]/reset/post"; import { filterTypePost } from "./filter/[scope]/type/post"; -import { homeGet } from "./get"; import { catalogGet } from "./help/catalog/get"; import { helpGet } from "./help/get"; import { modeGet } from "./mode/get"; @@ -43,10 +44,13 @@ import { rolesGet } from "./signups/roles/get"; // handlers live in files mirroring their route: the folder is the path (with // [param] segments) and the file is the method export const registerRoutes = (router: RingRouter) => { - router.get("/", homeGet); router.get("/help", helpGet); router.get("/help/catalog", catalogGet); router.get("/about", aboutGet); + router.route("/about/feedback", { + modal: aboutFeedbackModal, + post: aboutFeedbackPost, + }); // scoped panels answer their bare path as the global scope, so the scope-switch // select can target "{/:channelId}" and fall back to global when cleared diff --git a/src/routes/lib/components.ts b/src/routes/lib/components.ts index 447693b..c43a642 100644 --- a/src/routes/lib/components.ts +++ b/src/routes/lib/components.ts @@ -50,41 +50,25 @@ export const editSelectRow = < return new ActionRowBuilder().addComponents(builder).toJSON(); }; -export const homeButton = (router: RingRouter): RingButton => - new RouteButtonBuilder(router) - .setLabel("🏠 Home") - .setStyle(ButtonStyle.Secondary) - .setTo("/"); - export const backButton = (router: RingRouter, path: string): RingButton => new RouteButtonBuilder(router) .setLabel("Back") .setStyle(ButtonStyle.Secondary) .setTo(path); -// the top-level sections, one per entry in the section bar (SECTIONS below) -export type Section = - | "home" - | "signups" - | "filters" - | "ringees" - | "mode" - | "help" - | "about" - | "delete"; - -type Tab = { section: Section; label: string; path: string }; +type Tab = { emoji: string; label: string; path: string }; -// every section the bar offers, in display order +// every section the bar offers, in display order. The emoji rides the +// dedicated option field, not the label: labels render in the OS emoji font +// while the emoji field gets Discord's own artwork, matching embed titles const SECTIONS: readonly Tab[] = [ - { section: "home", label: "🏠 Home", path: "/" }, - { section: "signups", label: "🔔 Signups", path: "/signups" }, - { section: "filters", label: "đŸ›Ąī¸ Filters", path: "/filter/global" }, - { section: "ringees", label: "đŸ“Ŗ Ring", path: "/recipients/global" }, - { section: "mode", label: "💤 Mode", path: "/mode" }, - { section: "help", label: "📖 Help", path: "/help" }, - { section: "about", label: "â„šī¸ About", path: "/about" }, - { section: "delete", label: "đŸ—‘ī¸ Delete data", path: "/delete-data" }, + { emoji: "đŸ“Ŗ", label: "Ring", path: "/recipients/global" }, + { emoji: "🔔", label: "Signups", path: "/signups" }, + { emoji: "đŸ›Ąī¸", label: "Filters", path: "/filter/global" }, + { emoji: "💤", label: "Mode", path: "/mode" }, + { emoji: "📖", label: "Help", path: "/help" }, + { emoji: "â„šī¸", label: "About", path: "/about" }, + { emoji: "đŸ—‘ī¸", label: "Delete data", path: "/delete-data" }, ]; // the persistent section bar every panel ends on. A string select fits all @@ -102,10 +86,11 @@ export const navBar = ( interaction.member.voice.channel ); - const option = ({ section, label, path }: Tab) => { - const target = section === "ringees" && inVoice ? "/ring" : path; + const option = ({ emoji, label, path }: Tab) => { + const target = path === "/recipients/global" && inVoice ? "/ring" : path; return new RouteStringSelectMenuOptionBuilder(router) .setTo(target) + .setEmoji(emoji) .setLabel(label); }; diff --git a/src/routes/lib/emoji.ts b/src/routes/lib/emoji.ts deleted file mode 100644 index ab7a6b3..0000000 --- a/src/routes/lib/emoji.ts +++ /dev/null @@ -1,8 +0,0 @@ -// branded custom emoji used as the panel author icon -export const RINGVC_EMOJI_ID = "1324809350899961877"; - -// the emoji image on the CDN, usable as an embed author or thumbnail icon. -// A plain image URL renders for anyone, so it needs no access check; an -// invalid id simply 404s and the icon is dropped -export const emojiIconURL = (id: string): string => - `https://cdn.discordapp.com/emojis/${id}.webp`; diff --git a/src/routes/notice/get.ts b/src/routes/notice/get.ts index 5bcac44..fed56a9 100644 --- a/src/routes/notice/get.ts +++ b/src/routes/notice/get.ts @@ -21,8 +21,8 @@ const openLabel = (path: string): string => { }; export const noticeGet: Handler<"GET"> = (router, interaction, state) => { - const to = state.queryParams.get("to") ?? "/"; - const [path = "/", query = ""] = to.split("?"); + const to = state.queryParams.get("to") ?? "/help"; + const [path = "/help", query = ""] = to.split("?"); const open = new RouteButtonBuilder(router) .setLabel(openLabel(path)) .setStyle(ButtonStyle.Secondary) diff --git a/src/routes/page-jump/modal.ts b/src/routes/page-jump/modal.ts index a3b5ceb..b92f7e7 100644 --- a/src/routes/page-jump/modal.ts +++ b/src/routes/page-jump/modal.ts @@ -14,7 +14,7 @@ export const pageJumpModal: Handler<"MODAL"> = (router, interaction, state) => { .setTo(PAGE_JUMP, { method: "POST", queryParams: { - to: query.get("to") ?? "/", + to: query.get("to") ?? "/help", page: query.get("page") ?? "0", pageCount, }, diff --git a/src/routes/page-jump/post.ts b/src/routes/page-jump/post.ts index 76afc42..a4b4f70 100644 --- a/src/routes/page-jump/post.ts +++ b/src/routes/page-jump/post.ts @@ -3,7 +3,7 @@ import { Handler } from "@routes/types"; export const pageJumpPost: Handler<"POST"> = (router, interaction, state) => { const query = state.queryParams; - const to = query.get("to") ?? "/"; + const to = query.get("to") ?? "/help"; const input = state.fields?.getTextInputValue("page")?.trim() ?? ""; const parsed = /^\d+$/.test(input) ? Number(input) : NaN; const pageCount = parseInt(query.get("pageCount") ?? "1", 10) || 1; diff --git a/supabase/migrations/20260725000000_feedback.sql b/supabase/migrations/20260725000000_feedback.sql new file mode 100644 index 0000000..4875543 --- /dev/null +++ b/supabase/migrations/20260725000000_feedback.sql @@ -0,0 +1,11 @@ +-- Feedback submitted through the About panel's feedback form. Anonymous by +-- design: only the text and a timestamp, never the submitter's identity. +create table feedback ( + id bigint generated always as identity primary key, + content text not null, + created_at timestamptz not null default now() +); + +grant select, insert, update, delete on feedback to service_role; + +alter table feedback enable row level security;