From 1d01ff4386b3e9e3ea84f1c5ac5f79f3303a6ad6 Mon Sep 17 00:00:00 2001 From: Philip Stayetski Date: Mon, 14 Sep 2026 14:35:59 -0700 Subject: [PATCH] Add an in-app feedback sheet Nothing in the app opened an issue or a message to the team: the only way to say something was to find the repository, sign in to GitHub and fill a template. The sheet is one click from the places where something goes wrong and from the account menu, and it sends the message to the service. What travels with a message is on the screen: which control opened the sheet, the route, the build and the browser, and a few facts the control attached, such as the kind of session being started and whether its machine was reachable. Nothing from a terminal: the app never has the plaintext, and the sheet says so. Where the link sits: the new session form, the session password gate, a session that ended, the empty sessions list, error notices beside Retry, the terminal-linked notice, the vault gates, the account menu, the Account page, and the delete account form. The service keeps each message in a feedback table and, when FEEDBACK_TO is set, forwards it by email; a mail failure is logged and the message is still kept. The hosted Worker sets it to founders@pilotprotocol.network, the address the Terms and Privacy pages already give. Five per person, then one every twelve minutes. Deleting an account keeps its messages and clears who sent them, which the privacy policy now says, as do the web app guide and llms.txt. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ueq619VZvd44TY7bF43KDR --- CHANGELOG.md | 9 + app/.env.example | 3 + app/README.md | 3 + app/docker-compose.yml | 2 + app/server/app.test.ts | 55 +++++ app/server/app.ts | 36 ++++ app/server/import-json.ts | 4 + app/server/index.ts | 1 + app/server/lib/config.ts | 6 + app/server/lib/mail-feedback.test.ts | 43 ++++ app/server/lib/mail.ts | 66 ++++++ app/server/lib/migrations/011_feedback.sql | 27 +++ app/server/lib/store-conformance.test.ts | 46 +++- app/server/lib/store-memory.ts | 25 ++- app/server/lib/store-postgres.ts | 56 +++++ app/server/lib/store.ts | 9 + app/server/lib/types.ts | 27 +++ app/server/routes/feedback.test.ts | 139 ++++++++++++ app/server/routes/feedback.ts | 136 ++++++++++++ app/src/App.tsx | 3 + app/src/components/AppShell.tsx | 16 +- app/src/components/DeleteAccount.tsx | 11 + app/src/components/NewSessionModal.tsx | 23 ++ app/src/components/SignedInModal.tsx | 8 + app/src/feedback/FeedbackLink.tsx | 31 +++ app/src/feedback/FeedbackProvider.tsx | 39 ++++ app/src/feedback/FeedbackSheet.tsx | 234 +++++++++++++++++++++ app/src/feedback/context.ts | 27 +++ app/src/lib/api.ts | 9 + app/src/lib/feedback.test.ts | 53 +++++ app/src/lib/feedback.ts | 138 ++++++++++++ app/src/main.tsx | 1 + app/src/routes/Account.tsx | 8 +- app/src/routes/Machines.tsx | 28 ++- app/src/routes/Privacy.tsx | 22 +- app/src/routes/Session.tsx | 26 ++- app/src/routes/Workspace.tsx | 37 +++- app/src/styles/feedback.css | 224 ++++++++++++++++++++ app/src/terminal/TerminalPane.tsx | 12 ++ app/src/vault/VaultGate.tsx | 13 +- app/src/vite-env.d.ts | 3 + app/vite.config.ts | 14 ++ app/worker/index.ts | 3 + app/wrangler.jsonc | 3 +- docs/content.json | 4 + public/llms.txt | 8 + 46 files changed, 1648 insertions(+), 43 deletions(-) create mode 100644 app/server/lib/mail-feedback.test.ts create mode 100644 app/server/lib/migrations/011_feedback.sql create mode 100644 app/server/routes/feedback.test.ts create mode 100644 app/server/routes/feedback.ts create mode 100644 app/src/feedback/FeedbackLink.tsx create mode 100644 app/src/feedback/FeedbackProvider.tsx create mode 100644 app/src/feedback/FeedbackSheet.tsx create mode 100644 app/src/feedback/context.ts create mode 100644 app/src/lib/feedback.test.ts create mode 100644 app/src/lib/feedback.ts create mode 100644 app/src/styles/feedback.css diff --git a/CHANGELOG.md b/CHANGELOG.md index dacc023..10770d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve ## Unreleased +### Added + +- A feedback form inside the web app. It opens from the account menu and the + Account page, and from the moments where something can go wrong: starting a + session, the session password gate, a session that ended, the vault setup + and unlock screens, error notices, the empty sessions list, and the delete + account form. Messages are kept by the service and, with `FEEDBACK_TO` + set, forwarded by email. Nothing from a terminal is attached. + ### Changed - Prevented Mermaid labels from being clipped by waiting for fonts before diff --git a/app/.env.example b/app/.env.example index eeb0975..ccecd7f 100644 --- a/app/.env.example +++ b/app/.env.example @@ -21,3 +21,6 @@ MAIL_PROVIDER=sendgrid MAIL_API_KEY= MAIL_FROM=shell.online # MAIL_API_URL=https://api.example.com/emails + +# Where feedback sent from the app is forwarded. Empty keeps it in the database only. +FEEDBACK_TO= diff --git a/app/README.md b/app/README.md index ebd09a2..88c038a 100644 --- a/app/README.md +++ b/app/README.md @@ -41,6 +41,7 @@ The client reads `VITE_FIREBASE_*` at build time. The server uses: | `RELAY_URL` | Relay proxied through `/relay/*` | | `MAIL_API_KEY`, `MAIL_FROM` | Optional invitation email | | `MAIL_PROVIDER`, `MAIL_API_URL` | Optional non-SendGrid JSON provider | +| `FEEDBACK_TO` | Optional address that feedback sent from the app is forwarded to | | `TRUST_PROXY` | Set to `1` only behind a trusted proxy | See [`.env.example`](.env.example) for the complete development configuration. @@ -78,6 +79,8 @@ npx wrangler deploy --config wrangler.deploy.jsonc ``` Set `MAIL_API_KEY` with `wrangler secret put` if invitation email is enabled. +Set `FEEDBACK_TO` as a Worker variable to have feedback sent from the app +forwarded by email; without it, feedback is kept in the `feedback` table only. Keep Hyperdrive query caching disabled because the app depends on read-after-write consistency. diff --git a/app/docker-compose.yml b/app/docker-compose.yml index 79f510a..4a18847 100644 --- a/app/docker-compose.yml +++ b/app/docker-compose.yml @@ -45,6 +45,8 @@ services: # Only true behind a proxy that rewrites X-Forwarded-For; believing it # otherwise lets any caller pick a new address per request. TRUST_PROXY: ${TRUST_PROXY:-0} + # Where feedback sent from the app is forwarded. Empty keeps it in the database only. + FEEDBACK_TO: ${FEEDBACK_TO:-} ports: - "${PORT:-8080}:8080" restart: unless-stopped diff --git a/app/server/app.test.ts b/app/server/app.test.ts index 14e66f4..545254d 100644 --- a/app/server/app.test.ts +++ b/app/server/app.test.ts @@ -2526,3 +2526,58 @@ describe("team audit key", () => { }); }); }); + +describe("feedback", () => { + const message = { + kind: "problem", + body: "The gate never opened.", + surface: "session-gate", + route: "/sessions?open=s1#k3y", + app_version: "0.15.1", + can_reply: true, + context: { host: "laptop", status: "ended" }, + }; + + it("keeps a message with who sent it and where from", async () => { + const posted = await call("POST", "/api/feedback", { auth: await idToken(), body: message }); + expect(posted.status).toBe(201); + expect(posted.body.feedback.id).toMatch(/^fbk_/); + const [kept] = await store.feedback(); + expect(kept).toMatchObject({ + uid: "uid-1", + email: "ana@example.com", + kind: "problem", + body: "The gate never opened.", + surface: "session-gate", + route: "/sessions", + appVersion: "0.15.1", + canReply: true, + context: { host: "laptop", status: "ended" }, + }); + expect(kept.orgId).toBeTruthy(); + }); + + it("refuses without a signed-in user", async () => { + const posted = await call("POST", "/api/feedback", { body: message }); + expect(posted.status).toBe(401); + expect(await store.feedback()).toEqual([]); + }); + + it("refuses an empty message", async () => { + const posted = await call("POST", "/api/feedback", { auth: await idToken(), body: { ...message, body: " " } }); + expect(posted.status).toBe(400); + expect(posted.body.error).toBe("write something first"); + }); + + it("stops a flood from one person", async () => { + for (let index = 0; index < 5; index += 1) { + expect((await call("POST", "/api/feedback", { auth: await idToken(), body: message })).status).toBe(201); + } + const sixth = await call("POST", "/api/feedback", { auth: await idToken(), body: message }); + expect(sixth.status).toBe(429); + expect(Number(sixth.headers["Retry-After"])).toBeGreaterThan(0); + /* Another person is not the flood. */ + const other = await call("POST", "/api/feedback", { auth: await idToken({ sub: "uid-2", email: "bo@example.com" }), body: message }); + expect(other.status).toBe(201); + }); +}); diff --git a/app/server/app.ts b/app/server/app.ts index f814ee8..a074a34 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -44,6 +44,7 @@ import { import { recordAudit, assignSession, auditCsv, SEALED_KINDS } from "./routes/audit"; import { addComment, inbox, notifyAssigned, notifySessionStarted } from "./routes/social"; import { deleteAccount } from "./routes/account"; +import { submitFeedback } from "./routes/feedback"; import { callerAddress, rateLimiter } from "./lib/rate-limit"; import { logMailer, type Mailer } from "./lib/mail"; @@ -70,6 +71,11 @@ export interface AppOptions { * an invite whose link is perfectly good. */ mailer?: Mailer; + /** + * Where feedback sent from the app is forwarded. Absent keeps it in the + * store only, which is still the record; the mail is for whoever reads it. + */ + feedbackTo?: string; /** * Serves the built client for anything that is not an API route. Present * only in a deployment that serves the app and the API together; in @@ -121,6 +127,12 @@ const CREDENTIAL_ROUTES = new Set([ ]); const CREDENTIAL_BUCKET = { burst: 12, perSecond: 0.2 }; const GENERAL_BUCKET = { burst: 240, perSecond: 40 }; +/* + * Feedback, per person rather than per address. The form is one click from + * most screens, and a stuck key or a script must not fill the inbox it + * forwards to. Five, then one more every twelve minutes. + */ +const FEEDBACK_BUCKET = { burst: 5, perSecond: 5 / 3600 }; /* * This service answers JSON to a known origin and serves no markup of its own, @@ -307,6 +319,7 @@ export function createApp(options: AppOptions) { const webOrigin = options.webOrigin ?? allowedOrigins[0] ?? ""; const credentialLimit = rateLimiter(CREDENTIAL_BUCKET); const generalLimit = rateLimiter(GENERAL_BUCKET); + const feedbackLimit = rateLimiter(FEEDBACK_BUCKET); async function sessionsForMember(membership: Membership, sessions: SessionRecord[]) { const states = options.sessionLiveness @@ -1424,6 +1437,29 @@ export function createApp(options: AppOptions) { return send(response, 201, { comment: result.value }); } + /* ---- Feedback ---- */ + + if (route === "POST /api/feedback") { + const membership = await requireMember(request); + if (!membership) return send(response, 401, { error: "sign in first" }); + const allowance = feedbackLimit.take(membership.uid); + if (!allowance.ok) { + response.setHeader("Retry-After", String(Math.ceil(allowance.retryAfterMs / 1000))); + return send(response, 429, { error: "That is plenty for now. Try again in a little while." }); + } + const result = await submitFeedback( + store, + mailer, + membership, + await readBody(request), + String(request.headers["user-agent"] ?? ""), + options.feedbackTo, + log, + ); + if (!result.ok) return send(response, result.status, { error: result.error }); + return send(response, 201, { feedback: { id: result.value.id, at: result.value.at } }); + } + /* ---- Inbox ---- */ if (route === "GET /api/notifications") { diff --git a/app/server/import-json.ts b/app/server/import-json.ts index 9899d18..c4295e8 100644 --- a/app/server/import-json.ts +++ b/app/server/import-json.ts @@ -89,6 +89,10 @@ async function main(): Promise { await copy(`notification ${notification.id}`, () => target.putNotification(notification)); } + for (const feedback of await source.feedback(Number.MAX_SAFE_INTEGER)) { + await copy(`feedback ${feedback.id}`, () => target.putFeedback(feedback)); + } + console.log(`import: ${copied} records copied from ${file}, ${skipped} already present`); } finally { await target.close(); diff --git a/app/server/index.ts b/app/server/index.ts index 262d2c0..12bdd52 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -49,6 +49,7 @@ const server = createAccountsServer({ webOrigin: config.webOrigin, trustProxy: config.trustProxy, mailer: createMailer(config.mail), + feedbackTo: config.feedbackTo, serveClient: config.clientDir ? staticFiles(config.clientDir) : undefined, relay: forward ?? undefined, sessionLiveness: config.relayUrl ? relaySessionLiveness(config.relayUrl) : undefined, diff --git a/app/server/lib/config.ts b/app/server/lib/config.ts index da02a2c..1a1280c 100644 --- a/app/server/lib/config.ts +++ b/app/server/lib/config.ts @@ -45,6 +45,11 @@ export interface Config { * so an unconfigured deployment still creates a perfectly good invite. */ mail: { provider?: "sendgrid" | "json"; apiUrl?: string; apiKey?: string; from?: string }; + /** + * Where feedback sent from the app is forwarded by email. Absent keeps it + * in the database only, which is still the record. + */ + feedbackTo?: string; } export class ConfigError extends Error {} @@ -169,5 +174,6 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): Config { apiKey: env.MAIL_API_KEY?.trim() || undefined, from: env.MAIL_FROM?.trim() || undefined, }, + feedbackTo: env.FEEDBACK_TO?.trim() || undefined, }; } diff --git a/app/server/lib/mail-feedback.test.ts b/app/server/lib/mail-feedback.test.ts new file mode 100644 index 0000000..9a2a304 --- /dev/null +++ b/app/server/lib/mail-feedback.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { feedbackMessage } from "./mail"; +import type { Feedback } from "./types"; + +const feedback: Feedback = { + id: "fbk_1", + uid: "uid-1", + email: "ana@example.com", + orgId: "org_1", + kind: "problem", + body: "The gate never opened & I gave up.", + surface: "session-gate", + route: "/sessions", + appVersion: "0.15.1", + userAgent: "Chrome 129 on macOS", + canReply: false, + context: { host: "laptop" }, + at: Date.UTC(2026, 8, 14, 12, 0, 0), +}; + +describe("feedbackMessage", () => { + it("says what kind of message it is and where it came from", () => { + const message = feedbackMessage(feedback, "team@example.com"); + expect(message.to).toBe("team@example.com"); + expect(message.subject).toBe("[shell.online feedback] Problem: The gate never opened & I gave up."); + expect(message.text).toContain("session-gate on /sessions"); + expect(message.text).toContain("host: laptop"); + expect(message.text).toContain("asked not to be written to"); + }); + + it("escapes what the sender typed before it is rendered as HTML", () => { + const message = feedbackMessage(feedback, "team@example.com"); + expect(message.html).not.toContain("never"); + expect(message.html).toContain("<b>never</b>"); + expect(message.html).toContain("& I gave up."); + }); + + it("shortens a long first line in the subject", () => { + const message = feedbackMessage({ ...feedback, body: `${"word ".repeat(30)}end` }, "team@example.com"); + expect(message.subject.length).toBeLessThan(110); + expect(message.subject.endsWith("…")).toBe(true); + }); +}); diff --git a/app/server/lib/mail.ts b/app/server/lib/mail.ts index 9fc96a8..839b7bf 100644 --- a/app/server/lib/mail.ts +++ b/app/server/lib/mail.ts @@ -1,3 +1,5 @@ +import type { Feedback } from "./types"; + /** * Sending an invitation by email. * @@ -322,3 +324,67 @@ export function invitationMessage(invitation: Invitation, now = Date.now()): Mes return { to: invitation.to, subject, html, text }; } + +/* ---- Feedback, forwarded to whoever reads it ---- */ + +const KIND_WORD: Record = { + problem: "Problem", + idea: "Idea", + question: "Question", +}; + +/** The first line of a message, short enough for a subject. */ +function summarize(body: string, limit = 60): string { + const flat = body.replace(/\s+/g, " ").trim(); + return flat.length > limit ? `${flat.slice(0, limit).trimEnd()}…` : flat; +} + +/** + * One message per piece of feedback, as plain as a forwarded note. + * + * Everything in it was typed by the sender or attached by their browser, so + * all of it is escaped. The reply address is in the body rather than in a + * Reply-To header: the sender chose whether to be written back to, and a + * header the mail client acts on by itself would make that choice for them. + */ +export function feedbackMessage(feedback: Feedback, to: string): Message { + const kind = KIND_WORD[feedback.kind]; + const subject = `[shell.online feedback] ${kind}: ${summarize(feedback.body)}`; + const reply = feedback.canReply + ? `${feedback.email} said it is fine to reply.` + : `${feedback.email} asked not to be written to about this.`; + const facts: [string, string][] = [ + ["From", reply], + ["Where", `${feedback.surface} on ${feedback.route || "/"}`], + ["App", `${feedback.appVersion || "unknown"} · ${feedback.userAgent || "unknown browser"}`], + ["Sent", new Date(feedback.at).toISOString()], + ...Object.entries(feedback.context).map(([key, value]): [string, string] => [key, value]), + ["Id", feedback.id], + ]; + + const text = [ + `${kind} from ${feedback.email}`, + "", + feedback.body, + "", + ...facts.map(([key, value]) => `${key}: ${value}`), + ].join("\n"); + + const rows = facts + .map( + ([key, value]) => + `${escape(key)}${escape(value)}`, + ) + .join(""); + const html = ` + +${escape(subject)} + +

${escape(kind)} sent from the shell.online app

+
${escape(feedback.body)}
+ ${rows}
+ +`; + + return { to, subject, html, text }; +} diff --git a/app/server/lib/migrations/011_feedback.sql b/app/server/lib/migrations/011_feedback.sql new file mode 100644 index 0000000..3106d61 --- /dev/null +++ b/app/server/lib/migrations/011_feedback.sql @@ -0,0 +1,27 @@ +-- What people tell us from inside the app. +-- +-- One row per message, with where in the app it was written: the control that +-- opened the form, the route, the build and the browser, and a few facts the +-- control attached, such as which kind of session was being started. Nothing +-- from a terminal: the app never has the plaintext, and the form says so. +-- +-- A message belongs to the service rather than to a team, so deleting the +-- account that sent it clears who sent it and keeps the words, the same way the +-- activity trail keeps what was typed. +CREATE TABLE IF NOT EXISTS feedback ( + id TEXT PRIMARY KEY, + uid TEXT NOT NULL, + email TEXT NOT NULL, + org_id TEXT, + kind TEXT NOT NULL CHECK (kind IN ('problem', 'idea', 'question')), + body TEXT NOT NULL, + surface TEXT NOT NULL, + route TEXT NOT NULL, + app_version TEXT NOT NULL, + user_agent TEXT NOT NULL, + can_reply BOOLEAN NOT NULL DEFAULT FALSE, + context JSONB NOT NULL DEFAULT '{}'::jsonb, + at BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS feedback_at ON feedback (at DESC); diff --git a/app/server/lib/store-conformance.test.ts b/app/server/lib/store-conformance.test.ts index 918ec33..712c816 100644 --- a/app/server/lib/store-conformance.test.ts +++ b/app/server/lib/store-conformance.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { MemoryStore } from "./store-memory"; import { PostgresStore } from "./store-postgres"; import { DELETED_ACCOUNT_MEMORY_MS, DELETED_ACTOR_EMAIL, type Store } from "./store"; -import type { AgentCommand, AuditEvent, CliToken, Notification, SessionRecord } from "./types"; +import type { AgentCommand, AuditEvent, CliToken, Feedback, Notification, SessionRecord } from "./types"; import type { Invite, Membership, Organization } from "./orgs"; /** @@ -127,7 +127,27 @@ function notification(overrides: Partial = {}): Notification { type Implementation = { name: string; open: () => Promise; reset: (store: Store) => Promise }; +function feedback(overrides: Partial = {}): Feedback { + return { + id: "fbk_1", + uid: "uid-1", + email: "ana@example.com", + orgId: "org_1", + kind: "problem", + body: "The gate did not open", + surface: "session-gate", + route: "/sessions", + appVersion: "0.15.1", + userAgent: "Chrome 129 on macOS", + canReply: true, + context: { host: "laptop" }, + at: 1000, + ...overrides, + }; +} + const TABLES = [ + "feedback", "deleted_accounts", "account_keys", "session_key_shares", @@ -870,6 +890,22 @@ for (const implementation of implementations) { }); }); + describe("feedback", () => { + it("keeps a message with where it came from, newest first", async () => { + await store.putFeedback(feedback()); + await store.putFeedback(feedback({ id: "fbk_2", kind: "idea", at: 3000, context: {} })); + const listed = await store.feedback(); + expect(listed.map((entry) => entry.id)).toEqual(["fbk_2", "fbk_1"]); + expect(listed[1]).toEqual(feedback()); + expect(await store.feedback(1)).toHaveLength(1); + }); + + it("orders messages sent in the same millisecond the same way every time", async () => { + for (const id of ["fbk_b", "fbk_c", "fbk_a"]) await store.putFeedback(feedback({ id })); + expect((await store.feedback()).map((entry) => entry.id)).toEqual(["fbk_c", "fbk_b", "fbk_a"]); + }); + }); + describe("claiming an organization on first sight", () => { /* * Signing in fires several requests at once. On a new account none of @@ -991,6 +1027,8 @@ for (const implementation of implementations) { }); await store.putNotification(notification()); await store.putNotification(notification({ id: "ntf_2", uid: "uid-1", actorUid: "uid-2" })); + await store.putFeedback(feedback()); + await store.putFeedback(feedback({ id: "fbk_bo", uid: "uid-2", email: "bo@example.com" })); await store.deleteAccount("uid-1", { orgId: "org_1", dissolve: false, successorUid: "uid-3" }, 5000); @@ -1005,6 +1043,12 @@ for (const implementation of implementations) { expect(await store.notificationsFor("org_1", "uid-1")).toEqual([]); expect(await store.notificationsFor("org_1", "uid-2")).toEqual([]); + /* Feedback is a message to the service: the words stay, the sender does not. */ + const messages = await store.feedback(); + const anas = messages.find((entry) => entry.id === "fbk_1"); + expect(anas).toMatchObject({ uid: "", email: DELETED_ACTOR_EMAIL, canReply: false, body: "The gate did not open" }); + expect(messages.find((entry) => entry.id === "fbk_bo")).toMatchObject({ uid: "uid-2", email: "bo@example.com" }); + const kept = (await store.listOrgSessions("org_1")).find((entry) => entry.id === "s2"); expect(kept?.assigneeUids).toEqual(["uid-3"]); expect(kept?.assigneeUid).toBe("uid-3"); diff --git a/app/server/lib/store-memory.ts b/app/server/lib/store-memory.ts index 0e7dd54..6387f72 100644 --- a/app/server/lib/store-memory.ts +++ b/app/server/lib/store-memory.ts @@ -18,6 +18,7 @@ import type { CliToken, Comment, Device, + Feedback, Notification, SessionKeyShare, SessionRecord, @@ -65,6 +66,7 @@ interface Shape { audit: AuditEvent[]; comments: Comment[]; notifications: Notification[]; + feedback: Feedback[]; accountKeys: AccountKey[]; deletedAccounts: { uid: string; deletedAt: number }[]; teamKeys: TeamKey[]; @@ -74,7 +76,7 @@ interface Shape { const EMPTY: Shape = { codes: [], tokens: [], sessions: [], commands: [], organizations: [], memberships: [], invites: [], audit: [], - comments: [], notifications: [], accountKeys: [], deletedAccounts: [], + comments: [], notifications: [], feedback: [], accountKeys: [], deletedAccounts: [], teamKeys: [], teamKeyShares: [], }; @@ -138,6 +140,7 @@ export class MemoryStore implements Store { audit: parsed.audit ?? [], comments: parsed.comments ?? [], notifications: parsed.notifications ?? [], + feedback: parsed.feedback ?? [], accountKeys: parsed.accountKeys ?? [], deletedAccounts: parsed.deletedAccounts ?? [], teamKeys: parsed.teamKeys ?? [], @@ -341,6 +344,13 @@ export class MemoryStore implements Store { for (const event of data.audit) { if (event.actorUid === uid) event.actorEmail = DELETED_ACTOR_EMAIL; } + /* The words stay, as a message to the service; who sent them does not. */ + for (const entry of data.feedback) { + if (entry.uid !== uid) continue; + entry.uid = ""; + entry.email = DELETED_ACTOR_EMAIL; + entry.canReply = false; + } data.deletedAccounts = [ ...data.deletedAccounts.filter((entry) => entry.uid !== uid), { uid, deletedAt: now }, @@ -837,6 +847,19 @@ export class MemoryStore implements Store { return this.data.organizations; } + /* ---- Feedback ---- */ + + async putFeedback(feedback: Feedback): Promise { + this.data.feedback.push(feedback); + this.flush(); + } + + async feedback(limit = 100): Promise { + return [...this.data.feedback] + .sort(byTime((entry) => entry.at, (entry) => entry.id, true)) + .slice(0, limit); + } + async tokensForImport(): Promise { return this.data.tokens; } diff --git a/app/server/lib/store-postgres.ts b/app/server/lib/store-postgres.ts index c5068b2..7c5ce01 100644 --- a/app/server/lib/store-postgres.ts +++ b/app/server/lib/store-postgres.ts @@ -20,6 +20,7 @@ import type { CliToken, Comment, Device, + Feedback, Notification, SessionKeyShare, SessionRecord, @@ -315,6 +316,24 @@ function toNotification(row: Row): Notification { }) as unknown as Notification; } +function toFeedback(row: Row): Feedback { + return { + id: row.id as string, + uid: row.uid as string, + email: row.email as string, + ...(row.org_id ? { orgId: row.org_id as string } : {}), + kind: row.kind as Feedback["kind"], + body: row.body as string, + surface: row.surface as string, + route: row.route as string, + appVersion: row.app_version as string, + userAgent: row.user_agent as string, + canReply: row.can_reply as boolean, + context: (row.context ?? {}) as Record, + at: row.at as number, + }; +} + /** * The production store. * @@ -992,6 +1011,11 @@ export class PostgresStore implements Store { uid, DELETED_ACTOR_EMAIL, ]); + /* The words stay, as a message to the service; who sent them does not. */ + await client.query("UPDATE feedback SET uid = '', email = $2, can_reply = FALSE WHERE uid = $1", [ + uid, + DELETED_ACTOR_EMAIL, + ]); await client.query( `INSERT INTO deleted_accounts (uid, deleted_at) VALUES ($1, $2) ON CONFLICT (uid) DO UPDATE SET deleted_at = EXCLUDED.deleted_at`, @@ -1519,6 +1543,38 @@ export class PostgresStore implements Store { return result.rowCount ?? 0; } + /* ---- Feedback ---- */ + + async putFeedback(feedback: Feedback): Promise { + await this.pool.query( + `INSERT INTO feedback (id, uid, email, org_id, kind, body, surface, route, app_version, user_agent, can_reply, context, at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + [ + feedback.id, + feedback.uid, + feedback.email, + feedback.orgId ?? null, + feedback.kind, + feedback.body, + feedback.surface, + feedback.route, + feedback.appVersion, + feedback.userAgent, + feedback.canReply, + JSON.stringify(feedback.context), + feedback.at, + ], + ); + } + + async feedback(limit = 100): Promise { + const rows = await this.rows( + 'SELECT * FROM feedback ORDER BY at DESC, id COLLATE "C" DESC LIMIT $1', + [limit], + ); + return rows.map(toFeedback); + } + /* ---- Housekeeping ---- */ async purgeExpired(now = Date.now()): Promise { diff --git a/app/server/lib/store.ts b/app/server/lib/store.ts index 100f5c2..0545cfc 100644 --- a/app/server/lib/store.ts +++ b/app/server/lib/store.ts @@ -7,6 +7,7 @@ import type { CliToken, Comment, Device, + Feedback, Notification, SessionKeyShare, SessionRecord, @@ -229,6 +230,14 @@ export interface Store { markNotificationRead(orgId: string, uid: string, id: string, now?: number): Promise; markAllNotificationsRead(orgId: string, uid: string, now?: number): Promise; + /* ---- Feedback ---- */ + putFeedback(feedback: Feedback): Promise; + /** + * Newest first. For an operator's export and for tests; the app never lists + * it. Deleting an account keeps its messages and clears who sent them. + */ + feedback(limit?: number): Promise; + /* ---- Housekeeping ---- */ purgeExpired(now?: number): Promise; close(): Promise; diff --git a/app/server/lib/types.ts b/app/server/lib/types.ts index 85604e8..b301cc4 100644 --- a/app/server/lib/types.ts +++ b/app/server/lib/types.ts @@ -169,6 +169,33 @@ export interface Notification { readAt?: number; } +/** + * Something a person told us from inside the app. + * + * Kept as it was sent, with where in the app it was written, so a report can + * be read without asking the reporter which screen they meant. Nothing from a + * terminal is in it: the app never has the plaintext, and the form says so. + */ +export interface Feedback { + id: string; + uid: string; + email: string; + orgId?: string; + kind: "problem" | "idea" | "question"; + body: string; + /** Which control opened the form: "new-session", "session-gate", and so on. */ + surface: string; + /** The app route it was sent from, path only. */ + route: string; + appVersion: string; + userAgent: string; + /** Whether the sender is happy to be written to about it. */ + canReply: boolean; + /** A few facts the surface attached, such as the kind of session being started. */ + context: Record; + at: number; +} + export interface SessionRecord { id: string; uid: string; diff --git a/app/server/routes/feedback.test.ts b/app/server/routes/feedback.test.ts new file mode 100644 index 0000000..49e4c1c --- /dev/null +++ b/app/server/routes/feedback.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import { MemoryStore } from "../lib/store-memory"; +import type { Membership } from "../lib/orgs"; +import type { Message } from "../lib/mail"; +import { MAX_FEEDBACK, readFeedback, submitFeedback } from "./feedback"; + +const ana: Membership = { + orgId: "org_1", + uid: "uid-1", + email: "ana@example.com", + name: "Ana", + role: "owner", + joinedAt: 1000, +}; + +const valid = { + kind: "idea", + body: " Let me pin a session. ", + surface: "account-menu", + route: "/account?tab=vault#nope", + app_version: "0.15.1", + can_reply: true, + context: { host: "laptop", count: 3, "Bad Key": "x", empty: " " }, +}; + +function mailbox() { + const sent: Message[] = []; + return { sent, mailer: { send: async (message: Message) => void sent.push(message) } }; +} + +describe("readFeedback", () => { + it("keeps the message and the place it came from, and nothing else", () => { + const read = readFeedback(valid); + expect(read.ok).toBe(true); + if (!read.ok) return; + expect(read.value).toEqual({ + kind: "idea", + body: "Let me pin a session.", + surface: "account-menu", + route: "/account", + appVersion: "0.15.1", + canReply: true, + context: { host: "laptop" }, + }); + }); + + it("needs a kind it knows", () => { + const read = readFeedback({ ...valid, kind: "rant" }); + expect(read).toMatchObject({ ok: false, status: 400 }); + }); + + it("needs something written", () => { + const read = readFeedback({ ...valid, body: " " }); + expect(read).toMatchObject({ ok: false, status: 400, error: "write something first" }); + }); + + it("caps the length", () => { + const read = readFeedback({ ...valid, body: "x".repeat(MAX_FEEDBACK + 1) }); + expect(read).toMatchObject({ ok: false, status: 400 }); + expect(readFeedback({ ...valid, body: "x".repeat(MAX_FEEDBACK) }).ok).toBe(true); + }); + + it("keeps the attached facts small", () => { + const context: Record = {}; + for (let index = 0; index < 12; index += 1) context[`key_${index}`] = "v".repeat(500); + const read = readFeedback({ ...valid, context }); + if (!read.ok) throw new Error("expected ok"); + expect(Object.keys(read.value.context)).toHaveLength(8); + expect(read.value.context.key_0).toHaveLength(200); + }); + + it("names an unknown surface rather than trusting one", () => { + const read = readFeedback({ ...valid, surface: "Weird Surface!" }); + if (!read.ok) throw new Error("expected ok"); + expect(read.value.surface).toBe("unknown"); + }); + + it("treats a missing reply choice as no", () => { + const read = readFeedback({ ...valid, can_reply: "yes" }); + if (!read.ok) throw new Error("expected ok"); + expect(read.value.canReply).toBe(false); + }); +}); + +describe("submitFeedback", () => { + it("stores the message and forwards it when an address is set", async () => { + const store = MemoryStore.memory(); + const { sent, mailer } = mailbox(); + const log = vi.fn(); + const result = await submitFeedback(store, mailer, ana, valid, "Mozilla/5.0 Chrome/129", "team@example.com", log, 5000); + expect(result.ok).toBe(true); + const [kept] = await store.feedback(); + expect(kept).toMatchObject({ + uid: "uid-1", + email: "ana@example.com", + orgId: "org_1", + kind: "idea", + body: "Let me pin a session.", + surface: "account-menu", + route: "/account", + userAgent: "Mozilla/5.0 Chrome/129", + canReply: true, + context: { host: "laptop" }, + at: 5000, + }); + expect(kept.id).toMatch(/^fbk_/); + expect(sent).toHaveLength(1); + expect(sent[0].to).toBe("team@example.com"); + expect(sent[0].subject).toContain("Idea"); + expect(sent[0].text).toContain("ana@example.com"); + expect(sent[0].text).toContain("fine to reply"); + expect(log).not.toHaveBeenCalled(); + }); + + it("stores without forwarding when no address is set", async () => { + const store = MemoryStore.memory(); + const { sent, mailer } = mailbox(); + await submitFeedback(store, mailer, ana, valid, "", undefined, vi.fn()); + expect(await store.feedback()).toHaveLength(1); + expect(sent).toEqual([]); + }); + + it("keeps the message when the mail provider fails", async () => { + const store = MemoryStore.memory(); + const log = vi.fn(); + const mailer = { send: async () => { throw new Error("sendgrid returned 401"); } }; + const result = await submitFeedback(store, mailer, ana, valid, "", "team@example.com", log); + expect(result.ok).toBe(true); + expect(await store.feedback()).toHaveLength(1); + expect(log).toHaveBeenCalledOnce(); + }); + + it("refuses what it cannot read without writing anything", async () => { + const store = MemoryStore.memory(); + const result = await submitFeedback(store, mailbox().mailer, ana, { kind: "idea", body: "" }, "", undefined, vi.fn()); + expect(result).toMatchObject({ ok: false, status: 400 }); + expect(await store.feedback()).toEqual([]); + }); +}); diff --git a/app/server/routes/feedback.ts b/app/server/routes/feedback.ts new file mode 100644 index 0000000..8f000dd --- /dev/null +++ b/app/server/routes/feedback.ts @@ -0,0 +1,136 @@ +import type { Feedback, Store } from "../lib/store"; +import { newId, type Membership } from "../lib/orgs"; +import { feedbackMessage, type Mailer } from "../lib/mail"; +import type { Outcome } from "./social"; + +export const MAX_FEEDBACK = 4000; +export const FEEDBACK_KINDS = ["problem", "idea", "question"] as const; +export type FeedbackKind = (typeof FEEDBACK_KINDS)[number]; + +/* How much of the app's own bookkeeping one message may carry. */ +const MAX_ROUTE = 200; +const MAX_VERSION = 40; +const MAX_USER_AGENT = 300; +const MAX_CONTEXT_ENTRIES = 8; +const MAX_CONTEXT_VALUE = 200; + +/** What the browser sends: the message, and where in the app it was written. */ +export interface FeedbackInput { + kind: FeedbackKind; + body: string; + surface: string; + route: string; + appVersion: string; + canReply: boolean; + context: Record; +} + +function isKind(value: unknown): value is FeedbackKind { + return typeof value === "string" && (FEEDBACK_KINDS as readonly string[]).includes(value); +} + +/* + * The route the message came from, path only. A query string can name a + * session (?open=...) and a hash is where a share link keeps its key, so + * neither is kept even when the client sends them. + */ +function readRoute(value: unknown): string { + if (typeof value !== "string") return ""; + const path = value.split(/[?#]/, 1)[0].trim(); + return path.startsWith("/") ? path.slice(0, MAX_ROUTE) : ""; +} + +/* + * Small facts the surface attached: which kind of session was being started, + * which machine, what the error said. Bounded in count and size, and keyed by + * identifiers rather than free text, so the column stays a handful of labels + * and never becomes a second message body. + */ +function readContext(value: unknown): Record { + const context: Record = {}; + if (typeof value !== "object" || value === null) return context; + let kept = 0; + for (const [key, raw] of Object.entries(value as Record)) { + if (kept >= MAX_CONTEXT_ENTRIES) break; + if (!/^[a-z][a-z0-9_]{0,31}$/.test(key) || typeof raw !== "string") continue; + const trimmed = raw.trim(); + if (!trimmed) continue; + context[key] = trimmed.slice(0, MAX_CONTEXT_VALUE); + kept += 1; + } + return context; +} + +export function readFeedback(body: unknown): Outcome { + const given = (typeof body === "object" && body !== null ? body : {}) as Record; + if (!isKind(given.kind)) { + return { ok: false, status: 400, error: "say whether this is a problem, an idea or a question" }; + } + const text = typeof given.body === "string" ? given.body.trim() : ""; + if (!text) return { ok: false, status: 400, error: "write something first" }; + if (text.length > MAX_FEEDBACK) { + return { + ok: false, + status: 400, + error: `keep it under ${MAX_FEEDBACK} characters; you can send more than one`, + }; + } + /* The surface is an identifier the app chose, so anything else is "unknown" rather than kept. */ + const surface = + typeof given.surface === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(given.surface) + ? given.surface + : "unknown"; + return { + ok: true, + value: { + kind: given.kind, + body: text, + surface, + route: readRoute(given.route), + appVersion: + typeof given.app_version === "string" ? given.app_version.trim().slice(0, MAX_VERSION) : "", + canReply: given.can_reply === true, + context: readContext(given.context), + }, + }; +} + +/** + * Keeps a message and, when an address is configured, forwards it. + * + * The row is the record; the email is a convenience for whoever reads them. + * So the mail is best effort, like an invitation: a provider having a bad + * afternoon must not turn a message somebody took the time to write into an + * error they have to read. + */ +export async function submitFeedback( + store: Store, + mailer: Mailer, + sender: Membership, + body: unknown, + userAgent: string, + forwardTo: string | undefined, + log: (message: string, error?: unknown) => void, + now = Date.now(), +): Promise> { + const read = readFeedback(body); + if (!read.ok) return read; + const feedback: Feedback = { + id: newId("fbk"), + uid: sender.uid, + email: sender.email, + orgId: sender.orgId, + ...read.value, + userAgent: userAgent.trim().slice(0, MAX_USER_AGENT), + at: now, + }; + await store.putFeedback(feedback); + if (forwardTo) { + try { + await mailer.send(feedbackMessage(feedback, forwardTo)); + } catch (error) { + log(`accounts: could not forward feedback ${feedback.id}`, error); + } + } + return { ok: true, value: feedback }; +} diff --git a/app/src/App.tsx b/app/src/App.tsx index 20f085e..a979ee8 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,6 +16,7 @@ import { Audit } from "./routes/Audit"; import { CliAuthorize } from "./routes/CliAuthorize"; import { VaultProvider } from "./vault/VaultProvider"; import { TeamKeyProvider } from "./vault/TeamKeyProvider"; +import { FeedbackProvider } from "./feedback/FeedbackProvider"; export default function App() { return ( @@ -23,6 +24,7 @@ export default function App() { + } /> } /> } /> + diff --git a/app/src/components/AppShell.tsx b/app/src/components/AppShell.tsx index d8b070a..4bb677c 100644 --- a/app/src/components/AppShell.tsx +++ b/app/src/components/AppShell.tsx @@ -1,12 +1,13 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { Link, NavLink, useNavigate } from "react-router-dom"; import { - Terminal, Desktop, User, UsersThree, ClockCounterClockwise, SignOut, Copy, Check, Warning, + Terminal, Desktop, User, UsersThree, ClockCounterClockwise, SignOut, Copy, Check, Warning, ChatCircleDots, } from "@phosphor-icons/react"; import { Inbox } from "./Inbox"; import { Avatar } from "./Avatar"; import { Wordmark } from "./Wordmark"; import { useAuth } from "../auth/AuthProvider"; +import { useFeedback } from "../feedback/context"; import { COPY_FAILED, useCopy } from "../lib/clipboard"; const NAV = [ @@ -52,6 +53,7 @@ function AccountMenu() { const [open, setOpen] = useState(false); const wrapper = useRef(null); const navigate = useNavigate(); + const feedback = useFeedback(); useEffect(() => { if (!open) return; @@ -96,6 +98,18 @@ function AccountMenu() { Account + {/* Always one click away, from the rail and from the phone's top bar alike. */} + + {/* The one moment a reason is on the tip of the tongue. */} +

+ + Tell us why you are leaving + +

); } diff --git a/app/src/components/NewSessionModal.tsx b/app/src/components/NewSessionModal.tsx index 95f5eda..d34e358 100644 --- a/app/src/components/NewSessionModal.tsx +++ b/app/src/components/NewSessionModal.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { X, Warning } from "@phosphor-icons/react"; import { Button } from "./Button"; import { Alert } from "./Alert"; +import { FeedbackLink } from "../feedback/FeedbackLink"; import { SESSION_KINDS, sessionName, @@ -320,6 +321,28 @@ export function NewSessionModal({ No linked machine is available. Run shell login on one.

)} + + {/* + * Under the buttons, where somebody who could not start a session + * ends up. The facts a report needs most go with it: what was being + * started, on which machine, whether it was reachable, and what the + * form said if it refused. + */} +
+ + Something off with starting a session? Tell us + +
diff --git a/app/src/components/SignedInModal.tsx b/app/src/components/SignedInModal.tsx index 67651e1..d8d9831 100644 --- a/app/src/components/SignedInModal.tsx +++ b/app/src/components/SignedInModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { CheckCircle } from "@phosphor-icons/react"; import { Button } from "./Button"; +import { FeedbackLink } from "../feedback/FeedbackLink"; interface SignedInModalProps { onClose(): void; @@ -59,6 +60,13 @@ export function SignedInModal({ onClose }: SignedInModalProps) { + {/* The first thing a new machine's owner sees; the first place to hear about it. */} + + Anything confusing about linking? Tell us + , diff --git a/app/src/feedback/FeedbackLink.tsx b/app/src/feedback/FeedbackLink.tsx new file mode 100644 index 0000000..39d3b2f --- /dev/null +++ b/app/src/feedback/FeedbackLink.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; +import { ChatCircleDots } from "@phosphor-icons/react"; +import { useFeedback, type FeedbackRequest } from "./context"; + +interface FeedbackLinkProps extends FeedbackRequest { + children?: ReactNode; + className?: string; +} + +/** + * The line that opens the feedback sheet, from wherever something might have + * gone wrong. Quiet on purpose: it sits under a form or a notice and must not + * compete with the action the person came for. + */ +export function FeedbackLink({ + children = "Something off? Tell us", + className, + ...request +}: FeedbackLinkProps) { + const { open } = useFeedback(); + return ( + + ); +} diff --git a/app/src/feedback/FeedbackProvider.tsx b/app/src/feedback/FeedbackProvider.tsx new file mode 100644 index 0000000..05b80b7 --- /dev/null +++ b/app/src/feedback/FeedbackProvider.tsx @@ -0,0 +1,39 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useLocation } from "react-router-dom"; +import { useAuth } from "../auth/AuthProvider"; +import { sendFeedback } from "../lib/api"; +import { routeForFeedback } from "../lib/feedback"; +import { FeedbackContext, type FeedbackRequest } from "./context"; +import { FeedbackSheet } from "./FeedbackSheet"; + +/** + * One sheet for the whole app, opened from wherever a FeedbackLink sits. + * + * Rendered here rather than by the link that opened it, so it outlives a + * modal that closes underneath it and reads the same on every screen. It + * needs a signed-in person: the message is recorded against the account, + * which is what makes a reply possible. + */ +export function FeedbackProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const location = useLocation(); + const [request, setRequest] = useState(null); + const open = useCallback((next: FeedbackRequest) => setRequest(next), []); + const close = useCallback(() => setRequest(null), []); + const value = useMemo(() => ({ open }), [open]); + + return ( + + {children} + {request && user && ( + + )} + + ); +} diff --git a/app/src/feedback/FeedbackSheet.tsx b/app/src/feedback/FeedbackSheet.tsx new file mode 100644 index 0000000..04c9c1c --- /dev/null +++ b/app/src/feedback/FeedbackSheet.tsx @@ -0,0 +1,234 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { createPortal } from "react-dom"; +import { CheckCircle, X } from "@phosphor-icons/react"; +import { Button } from "../components/Button"; +import { Alert } from "../components/Alert"; +import { + APP_VERSION, + FEEDBACK_KINDS, + MAX_FEEDBACK, + describeBrowser, + surfaceLabel, + trimContext, + type FeedbackKind, + type FeedbackPayload, +} from "../lib/feedback"; +import type { FeedbackRequest } from "./context"; + +export interface FeedbackSheetProps { + /** Whose message it is, shown next to the reply choice so nothing is implied. */ + email: string; + /** The route it is sent from, path only. */ + route: string; + request: FeedbackRequest; + /** Defaults to this browser's. A parameter so the sheet can be rendered elsewhere. */ + userAgent?: string; + onSend(payload: FeedbackPayload): Promise; + onClose(): void; +} + +/** + * The form itself: a kind, a message, what travels with it, and whether a + * reply is welcome. + * + * Everything sent is on the screen. The "sent with your message" list is the + * actual context, not a summary of it, so nobody has to wonder what the app + * attached on their behalf. Nothing from a terminal is ever in it: the app + * never has the plaintext, and the line under the list says so. + */ +export function FeedbackSheet({ + email, + route, + request, + userAgent = navigator.userAgent, + onSend, + onClose, +}: FeedbackSheetProps) { + const [kind, setKind] = useState(request.kind ?? "problem"); + const [text, setText] = useState(""); + const [canReply, setCanReply] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [sent, setSent] = useState(false); + const box = useRef(null); + const done = useRef(null); + const context = trimContext(request.context); + const option = FEEDBACK_KINDS.find((candidate) => candidate.id === kind) ?? FEEDBACK_KINDS[0]; + + useEffect(() => { + /* + * Capture phase, so Escape closes this sheet and stops there. It opens on + * top of other dialogs, which listen for the same key on the document, + * and one press must not close the form underneath as well. + */ + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.stopPropagation(); + onClose(); + }; + document.addEventListener("keydown", onKey, true); + /* Restoring the literal earlier value, not "", so a nested open is safe. */ + const previous = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey, true); + document.body.style.overflow = previous; + }; + }, [onClose]); + + /* The box first, then the one button left once the message has gone. */ + useEffect(() => { + const frame = requestAnimationFrame(() => (sent ? done.current : box.current)?.focus()); + return () => cancelAnimationFrame(frame); + }, [sent]); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + const body = text.trim(); + if (!body || busy) return; + setBusy(true); + setError(""); + try { + await onSend({ + kind, + body, + surface: request.surface, + route, + app_version: APP_VERSION, + can_reply: canReply, + context, + }); + setSent(true); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Could not send that. Try again."); + } finally { + setBusy(false); + } + } + + return createPortal( +
event.target === event.currentTarget && onClose()} + > +
+ {sent ? ( +
+ +

Sent. Thank you.

+

+ {canReply ? ( + <> + If we have a question, we will write to {email}. + + ) : ( + "We will read it and, as you asked, not write back." + )} +

+ +
+ ) : ( + <> +
+

Tell us

+ +
+ +
+ {request.prompt &&

{request.prompt}

} + +
+ {FEEDBACK_KINDS.map((candidate) => ( + + ))} +
+ +
+ +