Public: anyone can read this; the crew can change it.
Muting or unmuting a repository in the inbox keeps the inbox where it was and offers Undo in place #8446
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8429-muting-or-unmuting-a-repository-in-the-i → main 2 commits fork 2 threads · 2 unresolved
3 files
changed +99
−4
docs/features/asks-mute-undo.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # Muting from the inbox keeps you in place | |
| 2 | ||
| 3 | On `/asks`, "Mute owner/name" under a row mutes that repository. The form carries the page's query as `back`; after the POST you land on the same `/asks?…` (filters kept) with a toast: "Muted owner/name" and an **Undo** button that posts to `/asks/listen`. "Listening to owner/name again" does the reverse, with an Undo that mutes again. Plain forms and redirects: it works without JavaScript. | |
| 4 | ||
| 5 | - `back` is followed only when it is `/asks` or `/asks?…` with no `#`; anything else lands on `/asks`. | |
| 6 | - The toast shows only for a repository you can read. | |
| 7 | - The flash lives in the address (`?muted=<id>` / `?listened=<id>`), so a reload or a shared link shows it again, and Undo then acts on the current state. The Undo's own `back` drops the flash keys. | |
| 8 | - The terminal already does both: its asks screen mutes through the bulk "mute" verb and "listen again" lifts every mute (`src/tui/screens/dashboard.ts`), each with a toast. |
src/web/studio/asks_page.ts +54 −3
| @@ -242,11 +242,60 @@ const rowHead = (kind: Decision["kind"], n: number): string => { | ||
| 242 | 242 | return `${line.charAt(0).toUpperCase()}${line.slice(1)}`; |
| 243 | 243 | }; |
| 244 | 244 | |
| 245 | /** The flash's own query keys: dropped from the page a decision returns to, so Undo does not carry them. */ | |
| 246 | const FLASH_KEYS = ["muted", "listened"] as const; | |
| 247 | ||
| 248 | /** This page as a path and query without the flash, for a form's `back`. */ | |
| 249 | function here(ctx: Ctx): string { | |
| 250 | const q = new URLSearchParams(ctx.url.searchParams); | |
| 251 | for (const k of FLASH_KEYS) q.delete(k); | |
| 252 | const rest = q.toString(); | |
| 253 | return ctx.url.pathname === "/asks" && rest !== "" ? `/asks?${rest}` : "/asks"; | |
| 254 | } | |
| 255 | ||
| 256 | /** A `back` worth following after a mute: `/asks` or `/asks?…`, nothing else. */ | |
| 257 | function asksBack(form: Parameters<typeof backTo>[0]): string { | |
| 258 | const back = backTo(form, "/asks"); | |
| 259 | return back === "/asks" || (back.startsWith("/asks?") && !back.includes("#")) ? back : "/asks"; | |
| 260 | } | |
| 261 | ||
| 262 | /** The same page with one flash key set, the others cleared. */ | |
| 263 | function withFlash(back: string, key: (typeof FLASH_KEYS)[number], repoId: string): string { | |
| 264 | const [path = "/asks", query = ""] = back.split("?"); | |
| 265 | const q = new URLSearchParams(query); | |
| 266 | for (const k of FLASH_KEYS) q.delete(k); | |
| 267 | q.set(key, repoId); | |
| 268 | return `${path}?${q.toString()}`; | |
| 269 | } | |
| 270 | ||
| 271 | /** "Muted owner/name" or "Listening to owner/name again", with Undo, when the page was reached from a mute or listen. */ | |
| 272 | function muteFlash(ctx: Ctx, user: NonNullable<Ctx["auth"]["user"]>): Html | null { | |
| 273 | for (const key of FLASH_KEYS) { | |
| 274 | const id = ctx.url.searchParams.get(key); | |
| 275 | if (id === null || !/^[A-Za-z0-9_-]{1,64}$/.test(id)) continue; | |
| 276 | const repo = getRepoById(ctx.app.db, id); | |
| 277 | if (!repo || !canRead(ctx.app.db, repo, user)) continue; | |
| 278 | const name = `${repo.ownerName}/${repo.name}`; | |
| 279 | const muted = key === "muted"; | |
| 280 | return html`<div class="toast" data-tone="success" role="status"> | |
| 281 | ${icon("check")} | |
| 282 | <div class="toast-body">${muted ? `Muted ${name}` : `Listening to ${name} again`}</div> | |
| 283 | <form class="bar" method="post" action="${muted ? "/asks/listen" : "/asks/mute"}"> | |
| 284 | <input type="hidden" name="repo" value="${repo.id}" /> | |
| 285 | <input type="hidden" name="back" value="${here(ctx)}" /> | |
| 286 | ${btn("Undo", { size: "sm" })} | |
| 287 | </form> | |
| 288 | </div>`; | |
| 289 | } | |
| 290 | return null; | |
| 291 | } | |
| 292 | ||
| 245 | 293 | /** A row and, under it, the chip that mutes its repository (`asksMute`). */ |
| 246 | 294 | function mutableRow(ctx: Ctx, r: AskRow<Decision>, picked: ReadonlySet<string>): Html { |
| 247 | 295 | return html`${rowCard(ctx, r, picked)} |
| 248 | 296 | <form class="bar" method="post" action="/asks/mute"> |
| 249 | 297 | <input type="hidden" name="repo" value="${r.repo.id}" /> |
| 298 | <input type="hidden" name="back" value="${here(ctx)}" /> | |
| 250 | 299 | <button |
| 251 | 300 | type="submit" |
| 252 | 301 | class="btn" data-variant="ghost" data-size="sm" |
| @@ -334,7 +383,7 @@ function renderAsks(ctx: Ctx, top: AsksTop | undefined): Response { | ||
| 334 | 383 | const response = page({ |
| 335 | 384 | ctx, |
| 336 | 385 | title: "Asks", |
| 337 | body: html`<main class="stack"><h1 class="sr-only">Asks</h1>${onYouRegion(ctx, user, undefined, section.body)}<p class="small muted"><a href="/review">Review: one row per ask</a></p></main>`, | |
| 386 | body: html`<main class="stack"><h1 class="sr-only">Asks</h1>${muteFlash(ctx, user)}${onYouRegion(ctx, user, undefined, section.body)}<p class="small muted"><a href="/review">Review: one row per ask</a></p></main>`, | |
| 338 | 387 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${section.scriptJs}`, |
| 339 | 388 | }); |
| 340 | 389 | notePhase(ctx.request, "render", performance.now() - rendering); |
| @@ -739,7 +788,9 @@ export async function asksListen(ctx: Ctx): Promise<Response> { | ||
| 739 | 788 | .filter((r): r is string => typeof r === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(r)) |
| 740 | 789 | .slice(0, BULK_MAX); |
| 741 | 790 | removeMutes(ctx.app.db, ctx.auth.user.id, repos.map((r) => `repo:${r}`)); |
| 742 | return redirect("/asks"); | |
| 791 | const only = repos.length === 1 ? repos[0] : undefined; | |
| 792 | const back = asksBack(form); | |
| 793 | return redirect(only === undefined ? back : withFlash(back, "listened", only)); | |
| 743 | 794 | } |
| 744 | 795 | |
| 745 | 796 | /** POST /asks/mute — the viewer mutes one repository they can read: its asks leave their home, inbox and phone. */ |
| @@ -753,5 +804,5 @@ export async function asksMute(ctx: Ctx): Promise<Response> { | ||
| 753 | 804 | const repo = getRepoById(ctx.app.db, id); |
| 754 | 805 | if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) return notFoundPage(ctx); |
| 755 | 806 | muteRepo(ctx.app.db, ctx.auth.user.id, repo.id); |
| 756 | return redirect("/asks"); | |
| 807 | return redirect(withFlash(asksBack(form), "muted", repo.id)); | |
| 757 | 808 | } |
test/asks-mute.test.ts +37 −1
| @@ -145,7 +145,7 @@ describe("muting a repository's asks", () => { | ||
| 145 | 145 | |
| 146 | 146 | const res = await post(cookie, "/asks/mute", [["repo", repo.id]]); |
| 147 | 147 | expect(res.status).toBe(303); |
| 148 | expect(res.headers.get("location")).toBe("/asks"); | |
| 148 | expect(res.headers.get("location")).toBe(`/asks?muted=${repo.id}`); | |
| 149 | 149 | expect(mutedRepos(app.db, alice.id).has(repo.id)).toBe(true); |
| 150 | 150 | // Idempotent: twice is the same as once. |
| 151 | 151 | await post(cookie, "/asks/mute", [["repo", repo.id]]); |
| @@ -206,6 +206,42 @@ describe("muting a repository's asks", () => { | ||
| 206 | 206 | expect(repoAsks(app.db, alice, repo)).toEqual({ count: 1, muted: false }); |
| 207 | 207 | }); |
| 208 | 208 | |
| 209 | test("muting and listening keep the reader on the page they were on, with Undo in place", async () => { | |
| 210 | const page = (path: string) => fetch(`${servers.url}${path}`, { headers: headers(cookie) }).then((r) => r.text()); | |
| 211 | const res = await post(cookie, "/asks/mute", [["repo", repo.id], ["back", "/asks?kind=pulls"]]); | |
| 212 | expect(res.status).toBe(303); | |
| 213 | const to = res.headers.get("location")!; | |
| 214 | expect(to).toBe(`/asks?kind=pulls&muted=${repo.id}`); | |
| 215 | const flash = await page(to); | |
| 216 | expect(flash).toContain("Muted alice/muted"); | |
| 217 | expect(flash).toContain('action="/asks/listen"'); | |
| 218 | const back = await post(cookie, "/asks/listen", [["repo", repo.id], ["back", "/asks?kind=pulls"]]); | |
| 219 | expect(back.headers.get("location")).toBe(`/asks?kind=pulls&listened=${repo.id}`); | |
| 220 | expect(await page(back.headers.get("location")!)).toContain("Listening to alice/muted again"); | |
| 221 | for (const evil of ["https://evil.example/", "/settings", "//evil.example/asks", "/asksfoo?x=1"]) { | |
| 222 | const r = await post(cookie, "/asks/mute", [["repo", repo.id], ["back", evil]]); | |
| 223 | expect(r.headers.get("location")).toBe(`/asks?muted=${repo.id}`); | |
| 224 | } | |
| 225 | await post(cookie, "/asks/listen", [["repo", repo.id]]); | |
| 226 | }); | |
Add cases for an unreadable repo id, a malformed muted= value and a back with '#': the guards in muteFlash/asksBack are untested. |
||
| 227 | ||
| 228 | test("the flash and its back are guarded: unreadable, malformed and fragment cases render or follow nothing", async () => { | |
| 229 | const page = (c: string, path: string) => | |
| 230 | fetch(`${servers.url}${path}`, { headers: headers(c) }).then((r) => r.text()); | |
| 231 | const secret = await createRepo(app, alice, { name: "hush", visibility: "private" }); | |
| 232 | if (!secret.ok) throw new Error(String(secret.error)); | |
| 233 | // Bob cannot read it: no flash, even with the id in the address. | |
| 234 | expect(await page(bobCookie, `/asks?muted=${secret.value.id}`)).not.toContain("Muted alice/hush"); | |
| 235 | expect(await page(cookie, `/asks?muted=${secret.value.id}`)).toContain("Muted alice/hush"); | |
| 236 | // A malformed id renders nothing. | |
| 237 | for (const bad of ["../x", "a b", "x".repeat(65), ""]) | |
| 238 | expect(await page(cookie, `/asks?muted=${encodeURIComponent(bad)}`)).not.toContain('class="toast"'); | |
| 239 | // A back with a fragment falls back. | |
| 240 | const r = await post(cookie, "/asks/mute", [["repo", repo.id], ["back", "/asks?kind=pulls#x"]]); | |
| 241 | expect(r.headers.get("location")).toBe(`/asks?muted=${repo.id}`); | |
| 242 | await post(cookie, "/asks/listen", [["repo", repo.id]]); | |
| 243 | }); | |
| 244 | ||
| 209 | 245 | test("a chip leaves the inbox: the change's chip or its author's, and no other", async () => { |
| 210 | 246 | const bot = getUserByName(app.db, "bot")!; |
| 211 | 247 | const lines = () => askLinesFor(app.db, alice).length; |
new above
offline
Use the
toastcomponent (data-tone=success) with role=status on the toast div, and the Undo form inside it, instead of abarform with role=status.