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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8429-muting-or-unmuting-a-repository-in-the-i → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/8429-muting-or-unmuting-a-repository-in-the-i was merged into main as 6ec9226.

Review

Sign in to review this change.
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 => {
242242 return `${line.charAt(0).toUpperCase()}${line.slice(1)}`;
243243 };
244244
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">
outdated

Use the toast component (data-tone=success) with role=status on the toast div, and the Undo form inside it, instead of a bar form with 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
245293 /** A row and, under it, the chip that mutes its repository (`asksMute`). */
246294 function mutableRow(ctx: Ctx, r: AskRow<Decision>, picked: ReadonlySet<string>): Html {
247295 return html`${rowCard(ctx, r, picked)}
248296 <form class="bar" method="post" action="/asks/mute">
249297 <input type="hidden" name="repo" value="${r.repo.id}" />
298 <input type="hidden" name="back" value="${here(ctx)}" />
250299 <button
251300 type="submit"
252301 class="btn" data-variant="ghost" data-size="sm"
@@ -334,7 +383,7 @@ function renderAsks(ctx: Ctx, top: AsksTop | undefined): Response {
334383 const response = page({
335384 ctx,
336385 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>`,
338387 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${section.scriptJs}`,
339388 });
340389 notePhase(ctx.request, "render", performance.now() - rendering);
@@ -739,7 +788,9 @@ export async function asksListen(ctx: Ctx): Promise<Response> {
739788 .filter((r): r is string => typeof r === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(r))
740789 .slice(0, BULK_MAX);
741790 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));
743794 }
744795
745796 /** 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> {
753804 const repo = getRepoById(ctx.app.db, id);
754805 if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) return notFoundPage(ctx);
755806 muteRepo(ctx.app.db, ctx.auth.user.id, repo.id);
756 return redirect("/asks");
807 return redirect(withFlash(asksBack(form), "muted", repo.id));
757808 }
test/asks-mute.test.ts +37 −1
@@ -145,7 +145,7 @@ describe("muting a repository's asks", () => {
145145
146146 const res = await post(cookie, "/asks/mute", [["repo", repo.id]]);
147147 expect(res.status).toBe(303);
148 expect(res.headers.get("location")).toBe("/asks");
148 expect(res.headers.get("location")).toBe(`/asks?muted=${repo.id}`);
149149 expect(mutedRepos(app.db, alice.id).has(repo.id)).toBe(true);
150150 // Idempotent: twice is the same as once.
151151 await post(cookie, "/asks/mute", [["repo", repo.id]]);
@@ -206,6 +206,42 @@ describe("muting a repository's asks", () => {
206206 expect(repoAsks(app.db, alice, repo)).toEqual({ count: 1, muted: false });
207207 });
208208
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
209245 test("a chip leaves the inbox: the change's chip or its author's, and no other", async () => {
210246 const bot = getUserByName(app.db, "bot")!;
211247 const lines = () => askLinesFor(app.db, alice).length;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs