Public: anyone can read this; the crew can change it.
Reply on a feed post comments on the issue or pull it is about, without leaving the feed #8218
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8211-reply-on-a-feed-post-comments-on-the-iss → main 3 commits fork 3 threads · 3 unresolved
11 files
changed +647
−14
README.md +1 −1
| @@ -469,7 +469,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 469 | 469 | - [](docs/STUDIO.md) — the front door in full: the five words and where each is decided, the box, the three lists, a tell, a run and a change each on its own page, and the board. |
| 470 | 470 | - [](docs/TOUR.md) — every other page and screen, with each of its doors: the terminal over SSH, the home and its figures, review, signing in, the page-view beacon, the board's notes, long threads, who can see a repository, archive, where a repository came from, big directories, search, sharing to the forge, link-preview cards and choosing among variants. |
| 471 | 471 | - [](docs/ASKS.md) — what waits on you and how to answer it: waiting pulls, mutes, wave proposals, feed chips and the decider. |
| 472 | - [](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. The home composer keeps your draft across a reload. | |
| 472 | - [](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. The home composer keeps your draft across a reload. A post about an issue or pull offers Reply, which comments on it and returns to the feed; the terminal's Feed screen replies with `r`. | |
| 473 | 473 | - [](docs/PULLS.md) — resolving a conflicted pull without a clone. |
| 474 | 474 | - [](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories. |
| 475 | 475 | - [](docs/CLI.md) — the `forge` command on your machine: install, log in, and every command with its flags and one example. |
docs/FEED.md +8
| @@ -79,6 +79,14 @@ region for the live-regions client (etag, 304). A post: who did it, what it | ||
| 79 | 79 | says as a link into the thing, a small line of why, Steer (run |
| 80 | 80 | cards, posts to the run's `steer`) or Tell the crew (everything else, posts to |
| 81 | 81 | the app's `tell`), Dismiss and Mute app (the interactions API, same-origin). |
| 82 | Reply (#8211): a post whose link is an issue or a pull (`/:owner/:name/issues/:n` | |
| 83 | or `/pulls/:n`) also offers Reply, a plain form posting the comment to that | |
| 84 | page's own `/comments` handler with `back=feed`, so the same-origin check, body | |
| 85 | limit and escaping apply; it returns to `/feed` with the flash "Reply posted." | |
| 86 | (a fixed word, never a URL from the form); an empty or refused reply returns there too, with the reason. Reply is the card's one primary button; Tell drops to plain beside it. Only a signed-in viewer who can read | |
| 87 | the repository sees it (`src/feed/reply.ts`, the comment action's own rule). In | |
| 88 | the terminal, `Feed` in the palette lists the same posts and `r` replies through | |
| 89 | the same `addCommentThrough`. | |
| 82 | 90 | Keys: `j`/`k`/Enter are the shell's, `x` dismisses the focused card. |
| 83 | 91 | The `choose` card comes from `src/feed/choose.ts`, a run card offers Stop |
| 84 | 92 | (`src/web/studio/cards.ts`), and `?cursor=` pages past the first 20 |
src/feed/reply.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | /** | |
| 2 | * Who may reply on a feed post, and to what. A reply is a comment on the | |
| 3 | * issue or pull the post links to, so the rule is the comment action's own: | |
| 4 | * a signed-in viewer who can read the repository (`requireParticipant`). | |
| 5 | * The page and the terminal both ask here, so they offer Reply to the same | |
| 6 | * people on the same posts. | |
| 7 | */ | |
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { canRead, getRepo, type Repo } from "../core/repos.ts"; | |
| 10 | import type { User } from "../core/users.ts"; | |
| 11 | import type { FeedItem } from "./rank.ts"; | |
| 12 | ||
| 13 | const THREAD_LINK = /^\/([^/]+)\/([^/]+)\/(issues|pulls)\/(\d+)(?:#[\w-]*)?$/; | |
| 14 | ||
| 15 | export interface Thread { | |
| 16 | readonly owner: string; | |
| 17 | readonly name: string; | |
| 18 | readonly kind: "issues" | "pulls"; | |
| 19 | readonly number: number; | |
| 20 | } | |
| 21 | ||
| 22 | /** The issue or pull a post's link names, or undefined when it names anything else. */ | |
| 23 | export function threadOf(link: string): Thread | undefined { | |
| 24 | const m = THREAD_LINK.exec(link); | |
| 25 | return m === null | |
| 26 | ? undefined | |
| 27 | : { owner: m[1]!, name: m[2]!, kind: m[3] as Thread["kind"], number: Number(m[4]) }; | |
| 28 | } | |
| 29 | ||
| 30 | /** The path a reply posts to: the thread's comment form, without any anchor. */ | |
| 31 | export function replyPath(thread: Thread): string { | |
| 32 | return `/${thread.owner}/${thread.name}/${thread.kind}/${thread.number}/comments`; | |
| 33 | } | |
| 34 | ||
| 35 | /** The repository a viewer may reply in, or undefined: signed out, or one they cannot read. */ | |
| 36 | export function replyRepo(db: Database, viewer: User | undefined, app: string): Repo | undefined { | |
| 37 | if (viewer === undefined) return undefined; | |
| 38 | const [owner = "", name = ""] = app.split("/"); | |
| 39 | const repo = getRepo(db, owner, name); | |
| 40 | return repo !== undefined && canRead(db, repo, viewer) ? repo : undefined; | |
| 41 | } | |
| 42 | ||
| 43 | /** The thread a viewer may reply on from this post, with its repository; undefined when there is none. */ | |
| 44 | export function replyTarget( | |
| 45 | db: Database, | |
| 46 | viewer: User | undefined, | |
| 47 | item: Pick<FeedItem, "app" | "link">, | |
| 48 | ): { thread: Thread; repo: Repo } | undefined { | |
| 49 | const thread = threadOf(item.link); | |
| 50 | if (thread === undefined || `${thread.owner}/${thread.name}` !== item.app) return undefined; | |
| 51 | const repo = replyRepo(db, viewer, item.app); | |
| 52 | return repo === undefined ? undefined : { thread, repo }; | |
| 53 | } |
src/tui/screens/entries.ts +2
| @@ -19,6 +19,7 @@ import { entry as connections } from "./connections.ts"; | ||
| 19 | 19 | import { entry as crewHandover } from "./crewhandover.ts"; |
| 20 | 20 | import { entry as crewSplits } from "./crewsplits.ts"; |
| 21 | 21 | import { entry as domains } from "./domains.ts"; |
| 22 | import { entry as feed } from "./feed.ts"; | |
| 22 | 23 | import { entry as front } from "./front.ts"; |
| 23 | 24 | import { entry as forges } from "./forges.ts"; |
| 24 | 25 | import { entry as invites } from "./invites.ts"; |
| @@ -83,6 +84,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 83 | 84 | cloud, |
| 84 | 85 | connections, |
| 85 | 86 | domains, |
| 87 | feed, | |
| 86 | 88 | front, |
| 87 | 89 | owner, |
| 88 | 90 | forges, |
src/tui/screens/feed.ts added +120
| @@ -0,0 +1,120 @@ | ||
| 1 | /** | |
| 2 | * The feed in the terminal — what happened, ranked, as the page and MCP | |
| 3 | * `feed` give it (`readFeed`), and `r` to reply. | |
| 4 | * | |
| 5 | * A reply is a comment on the issue or pull the post links to: the same | |
| 6 | * `addCommentThrough` the issue and pull screens call, offered on the same | |
| 7 | * posts to the same people as the page's Reply (`feed/reply.ts`). Twenty | |
| 8 | * posts at a time; `n` reads the next page from the cursor the last returned. | |
| 9 | */ | |
| 10 | ||
| 11 | import { addCommentThrough } from "../../actions/issues.ts"; | |
| 12 | import { getIssue } from "../../core/issues.ts"; | |
| 13 | import { parseCursor, readFeed } from "../../feed/feed.ts"; | |
| 14 | import type { FeedItem } from "../../feed/rank.ts"; | |
| 15 | import { replyTarget } from "../../feed/reply.ts"; | |
| 16 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 17 | import { binding } from "../app/keys.ts"; | |
| 18 | import type { Session } from "../session.ts"; | |
| 19 | import { fill, len, split } from "../ui/layout.ts"; | |
| 20 | import { emptyState } from "../ui/widgets.ts"; | |
| 21 | import { ListState, ago, drawList, errorText, runAsync, type ListRow } from "./common.ts"; | |
| 22 | import type { PaletteEntry } from "./entries.ts"; | |
| 23 | import { Composer } from "./thread.ts"; | |
| 24 | ||
| 25 | const PAGE = 20; | |
| 26 | ||
| 27 | const oneLine = (text: string): string => text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " "); | |
| 28 | ||
| 29 | export function feedScreen(f: Session, ctx: Ctx): Screen { | |
| 30 | const state = new ListState(); | |
| 31 | const composer = new Composer(); | |
| 32 | const items: FeedItem[] = []; | |
| 33 | let next: string | null = null; | |
| 34 | ||
| 35 | const read = (cursor?: string): void => { | |
| 36 | if (f.user === undefined) return; | |
| 37 | const parsed = cursor === undefined ? undefined : parseCursor(cursor); | |
| 38 | const page = readFeed(f.db, f.user, { limit: PAGE, ...(parsed ? { cursor: parsed } : {}) }); | |
| 39 | items.push(...page.items); | |
| 40 | next = page.next_cursor; | |
| 41 | ctx.invalidate(); | |
| 42 | }; | |
| 43 | read(); | |
| 44 | ||
| 45 | /** The post under the cursor, when it is about a thread this person may reply on. */ | |
| 46 | const selected = () => { | |
| 47 | const item = items[state.index]; | |
| 48 | return item === undefined ? undefined : replyTarget(f.db, f.user, item); | |
| 49 | }; | |
| 50 | ||
| 51 | const submit = (): void => { | |
| 52 | const at = selected(); | |
| 53 | if (at === undefined) return; | |
| 54 | const issue = getIssue(f.db, at.repo.id, at.thread.number); | |
| 55 | if (issue === undefined) return; | |
| 56 | const body = composer.value; | |
| 57 | runAsync(ctx, async () => { | |
| 58 | const added = await addCommentThrough(f.app, f.user, at.repo, issue, body); | |
| 59 | if (!added.ok) { | |
| 60 | ctx.toast(errorText(added.error), "danger"); | |
| 61 | return; | |
| 62 | } | |
| 63 | composer.close(); | |
| 64 | ctx.toast("reply posted", "success"); | |
| 65 | }); | |
| 66 | }; | |
| 67 | ||
| 68 | return { | |
| 69 | title: "Feed", | |
| 70 | get keys() { | |
| 71 | return [ | |
| 72 | binding(["j", "k"], "move"), | |
| 73 | ...(selected() === undefined ? [] : [binding(["r"], "reply")]), | |
| 74 | ...(next === null ? [] : [binding(["n"], "next page")]), | |
| 75 | binding(["esc", "q"], "back"), | |
| 76 | ]; | |
| 77 | }, | |
| 78 | render(area, s) { | |
| 79 | const c = ctx.theme.c; | |
| 80 | if (items.length === 0) { | |
| 81 | emptyState(s, area, c, { title: "Nothing in the feed yet", body: "Posts about the apps you can see land here." }); | |
| 82 | return; | |
| 83 | } | |
| 84 | const [list, box] = split(area, "v", [fill(1), len(composer.height())]); | |
| 85 | const rows: ListRow[] = items.map((item) => ({ | |
| 86 | title: oneLine(`${item.actor ?? item.app} ${item.what}`), | |
| 87 | secondary: item.app, | |
| 88 | meta: ago(item.at), | |
| 89 | })); | |
| 90 | drawList(s, list!, c, state, rows); | |
| 91 | if (composer.open) composer.render(s, box!, c, ctx); | |
| 92 | }, | |
| 93 | paste: (text) => composer.paste(text), | |
| 94 | key(e) { | |
| 95 | if (composer.open) { | |
| 96 | const out = composer.key(e); | |
| 97 | if (out === "submit") submit(); | |
| 98 | else if (out === "cancel") composer.close(); | |
| 99 | return true; | |
| 100 | } | |
| 101 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 102 | else if (e.key === "r") { | |
| 103 | const at = selected(); | |
| 104 | if (at !== undefined) composer.start(`Reply on ${at.thread.owner}/${at.thread.name} #${at.thread.number}`); | |
| 105 | } else if (e.key === "n") { | |
| 106 | if (next !== null) read(next); | |
| 107 | } else state.key(e, items.length); | |
| 108 | return true; | |
| 109 | }, | |
| 110 | }; | |
| 111 | } | |
| 112 | ||
| 113 | /** The feed, as the page's `/feed` has it. */ | |
| 114 | export const entry: PaletteEntry = { | |
| 115 | id: "feed", | |
| 116 | title: "Feed", | |
| 117 | hint: "what happened, ranked; r replies on a post about an issue or pull", | |
| 118 | offered: (session) => session.user !== undefined, | |
| 119 | run: (session, ctx) => ctx.push(feedScreen(session, ctx)), | |
| 120 | }; |
src/web/pages/issues.ts +16 −5
| @@ -264,6 +264,16 @@ export async function issuePrivateSubmit(ctx: Ctx): Promise<Response> { | ||
| 264 | 264 | }); |
| 265 | 265 | } |
| 266 | 266 | |
| 267 | /** | |
| 268 | * Where a comment returns to when the form names a fixed word: never a URL | |
| 269 | * from the form. A refused reply from the feed returns there too, as the | |
| 270 | * pull's does; one from the board keeps the issue page, where it always went. | |
| 271 | */ | |
| 272 | const BACK = new Map<string, { to: string; said: (n: number) => string; refusedTo?: string }>([ | |
| 273 | ["board", { to: "/board", said: (n) => `Answered on #${n}. The planner plans again.` }], | |
| 274 | ["feed", { to: "/feed", said: () => "Reply posted.", refusedTo: "/feed" }], | |
| 275 | ]); | |
| 276 | ||
| 267 | 277 | // POST /:owner/:name/issues/:number/comments |
| 268 | 278 | export async function issueCommentSubmit(ctx: Ctx): Promise<Response> { |
| 269 | 279 | const found = threadContext(ctx, "issue"); |
| @@ -274,11 +284,12 @@ export async function issueCommentSubmit(ctx: Ctx): Promise<Response> { | ||
| 274 | 284 | |
| 275 | 285 | // A comment posted from the board's "the planner declined" line returns to |
| 276 | 286 | // the board. A fixed word, never a URL from the form: no open redirect. |
| 277 | const back = field(form, "back") === "board" ? "/board" : undefined; | |
| 287 | const back = BACK.get(field(form, "back")); | |
| 278 | 288 | const dest = `${repoBase(found.repo)}/issues/${found.issue.number}`; |
| 289 | const refusedTo = back?.refusedTo ?? dest; | |
| 279 | 290 | const body = field(form, "body"); |
| 280 | 291 | if (body.trim() === "") |
| 281 | return flashTo(ctx, dest, { | |
| 292 | return flashTo(ctx, refusedTo, { | |
| 282 | 293 | tone: "danger", |
| 283 | 294 | message: COMMENT_EMPTY, |
| 284 | 295 | }); |
| @@ -290,14 +301,14 @@ export async function issueCommentSubmit(ctx: Ctx): Promise<Response> { | ||
| 290 | 301 | body, |
| 291 | 302 | ); |
| 292 | 303 | if (!added.ok) |
| 293 | return flashTo(ctx, dest, { | |
| 304 | return flashTo(ctx, refusedTo, { | |
| 294 | 305 | tone: "danger", |
| 295 | 306 | message: originError(added.error) ?? COMMENT_NOT_POSTED, |
| 296 | 307 | }); |
| 297 | 308 | return back === undefined |
| 298 | 309 | ? flashTo(ctx, `${dest}#conversation`, { message: "Comment posted." }) |
| 299 | : flashTo(ctx, back, { | |
| 300 | message: `Answered on #${found.issue.number}. The planner plans again.`, | |
| 310 | : flashTo(ctx, back.to, { | |
| 311 | message: back.said(found.issue.number), | |
| 301 | 312 | }); |
| 302 | 313 | } |
| 303 | 314 |
src/web/pages/pull.ts +7 −3
| @@ -1057,9 +1057,11 @@ export async function pullCommentSubmit(ctx: Ctx): Promise<Response> { | ||
| 1057 | 1057 | if (form instanceof Response) return form; |
| 1058 | 1058 | |
| 1059 | 1059 | const dest = `${repoBase(found.repo)}/pulls/${found.pull.number}`; |
| 1060 | // A reply from the feed returns there: a fixed word, never a URL from the form. | |
| 1061 | const fromFeed = field(form, "back") === "feed"; | |
| 1060 | 1062 | const bodyText = field(form, "body"); |
| 1061 | 1063 | if (bodyText.trim() === "") |
| 1062 | return flashTo(ctx, dest, { | |
| 1064 | return flashTo(ctx, fromFeed ? "/feed" : dest, { | |
| 1063 | 1065 | tone: "danger", |
| 1064 | 1066 | message: COMMENT_EMPTY, |
| 1065 | 1067 | }); |
| @@ -1071,11 +1073,13 @@ export async function pullCommentSubmit(ctx: Ctx): Promise<Response> { | ||
| 1071 | 1073 | bodyText, |
| 1072 | 1074 | ); |
| 1073 | 1075 | if (!added.ok) |
| 1074 | return flashTo(ctx, dest, { | |
| 1076 | return flashTo(ctx, fromFeed ? "/feed" : dest, { | |
| 1075 | 1077 | tone: "danger", |
| 1076 | 1078 | message: originError(added.error) ?? COMMENT_NOT_POSTED, |
| 1077 | 1079 | }); |
| 1078 | return flashTo(ctx, `${dest}`, { message: "Comment posted." }); | |
| 1080 | return fromFeed | |
| 1081 | ? flashTo(ctx, "/feed", { message: "Reply posted." }) | |
| 1082 | : flashTo(ctx, `${dest}`, { message: "Comment posted." }); | |
| 1079 | 1083 | } |
| 1080 | 1084 | |
| 1081 | 1085 | /** Match the diff viewer's per-file anchor id, so the redirect lands there. */ |
src/web/studio/feed.ts +24 −5
| @@ -18,6 +18,7 @@ import { placementsOf, type Placement } from "../../core/placements.ts"; | ||
| 18 | 18 | import { recentThoughts, type Thought } from "../../core/thoughts.ts"; |
| 19 | 19 | import { parseCursor, readFeedPage } from "../../feed/feed.ts"; |
| 20 | 20 | import type { FeedItem } from "../../feed/rank.ts"; |
| 21 | import { replyPath, replyRepo, threadOf } from "../../feed/reply.ts"; | |
| 21 | 22 | import { getAppByRepo } from "../../core/apps.ts"; |
| 22 | 23 | import { getRepo, getRepoById } from "../../core/repos.ts"; |
| 23 | 24 | import { DEFAULT_PAGE, looksForEnv, lookUrl } from "../../core/looks.ts"; |
| @@ -109,7 +110,12 @@ export function feedCard( | ||
| 109 | 110 | item: FeedItem, |
| 110 | 111 | marks: ReadonlyMap<string, Mark> = new Map(), |
| 111 | 112 | picture?: FeedPicture, |
| 113 | mayReply = false, | |
| 112 | 114 | ): Html { |
| 115 | // The thread must be in the post's own app: that is the repository `mayReply` was asked about. | |
| 116 | const linked = mayReply ? threadOf(item.link) : undefined; | |
| 117 | const thread = linked !== undefined && `${linked.owner}/${linked.name}` === item.app ? linked : undefined; | |
| 118 | const reply = thread !== undefined; | |
| 113 | 119 | const steer = item.kind === "run" || item.kind === "cost"; |
| 114 | 120 | const target = steer && RUN_LINK.test(item.link) ? `${item.link}/steer` : null; |
| 115 | 121 | const head = html`${avatar(item.actor ?? item.app)} |
| @@ -130,16 +136,22 @@ export function feedCard( | ||
| 130 | 136 | /></a>`} |
| 131 | 137 | ${saysMore(item) ? html`<p class="small muted break">${item.reason}</p>` : null}`; |
| 132 | 138 | const foot = disclosure( |
| 133 | `${target ? "Steer" : "Tell"} · more`, | |
| 134 | html`<form method="post" action="${target ?? `/${item.app}/tell`}" class="stack"> | |
| 139 | `${reply ? "Reply or " : ""}${target ? "Steer" : "Tell"} · more`, | |
| 140 | html`${reply | |
| 141 | ? html`<form method="post" action="${thread === undefined ? "" : replyPath(thread)}" class="stack"> | |
| 142 | <input type="hidden" name="back" value="feed" /> | |
| 143 | <textarea class="input" name="body" rows="2" maxlength="20000" required aria-label="Reply on ${item.what}"></textarea> | |
| 144 | <span class="bar">${btn("Reply", { size: "sm", variant: "primary" })}</span> | |
| 145 | </form>` | |
| 146 | : null}<form method="post" action="${target ?? `/${item.app}/tell`}" class="stack"> | |
| 135 | 147 | <textarea class="input" name="text" rows="2" maxlength="2000" required aria-label="${target ? "Steer this run" : "Tell the crew"}"></textarea> |
| 136 | <span class="bar">${btn(target ? "Steer" : "Tell", { size: "sm", variant: "primary" })}</span> | |
| 148 | <span class="bar">${btn(target ? "Steer" : "Tell", reply ? { size: "sm" } : { size: "sm", variant: "primary" })}</span> | |
| 137 | 149 | </form> |
| 138 | 150 | <span class="bar"> |
| 139 | 151 | <button type="button" class="btn" data-variant="ghost" data-size="sm" data-feed="dismissed" title="Dismiss (x)">Dismiss</button> |
| 140 | 152 | ${item.kind === "ask" || item.kind === "milestone" || item.kind === "choose" ? null : html`<button type="button" class="btn" data-variant="ghost" data-size="sm" data-feed="muted" title="Mute ${item.app}">Mute app</button>`} |
| 141 | 153 | </span>`, |
| 142 | { small: true, label: target ? "Steer, dismiss or mute" : "Tell the crew, dismiss or mute" }, | |
| 154 | { small: true, label: target ? "Steer, dismiss or mute" : reply ? "Reply, tell the crew, dismiss or mute" : "Tell the crew, dismiss or mute" }, | |
| 143 | 155 | ); |
| 144 | 156 | return card(body, { |
| 145 | 157 | head, |
| @@ -359,10 +371,17 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>): | ||
| 359 | 371 | // One divider per change of day between neighbours, in the order the feed already has. |
| 360 | 372 | const posts: Html[] = []; |
| 361 | 373 | let before = lastDayOnPage; |
| 374 | // Whether this viewer may reply in a repository, asked once per app on the page. | |
| 375 | const mayReply = new Map<string, boolean>(); | |
| 376 | const mayReplyOn = (app: string): boolean => { | |
| 377 | let may = mayReply.get(app); | |
| 378 | if (may === undefined) mayReply.set(app, (may = replyRepo(ctx.app.db, user, app) !== undefined)); | |
| 379 | return may; | |
| 380 | }; | |
| 362 | 381 | for (const item of items) { |
| 363 | 382 | const [label] = dividers([item.at], now, before); |
| 364 | 383 | if (label !== undefined) posts.push(html`<p class="muted small" role="separator" aria-label="${label}" data-day-divider="${label}">${label}</p>`); |
| 365 | posts.push(feedCard(item, marks, pictures.get(item.id))); | |
| 384 | posts.push(feedCard(item, marks, pictures.get(item.id), mayReplyOn(item.app))); | |
| 366 | 385 | before = item.at; |
| 367 | 386 | } |
| 368 | 387 | const liveQuery = [ |
test/feed-reply.test.ts added +263
| @@ -0,0 +1,263 @@ | ||
| 1 | /** | |
| 2 | * A reply on a feed post is a comment on the issue or pull it is about: the | |
| 3 | * card offers it, the same handler takes it, and the person lands back on the | |
| 4 | * home with a flash. A reader who cannot see the repository is refused. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { openIssue } from "../src/actions/issues.ts"; | |
| 11 | import { openPull } from "../src/actions/pulls.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { feedCard } from "../src/web/studio/feed.ts"; | |
| 15 | import type { FeedItem } from "../src/feed/rank.ts"; | |
| 16 | import { replyRepo } from "../src/feed/reply.ts"; | |
| 17 | import { listComments } from "../src/core/issues.ts"; | |
| 18 | import { createSession } from "../src/core/sessions.ts"; | |
| 19 | import { createUser } from "../src/core/users.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | import { promote } from "./helpers/members.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | let root = ""; | |
| 27 | let app: App; | |
| 28 | let servers: Servers; | |
| 29 | let aliceCookie = ""; | |
| 30 | let bobCookie = ""; | |
| 31 | let publicIssue = 0; | |
| 32 | let secretIssue = 0; | |
| 33 | let publicId = ""; | |
| 34 | let secretId = ""; | |
| 35 | let pullNumber = 0; | |
| 36 | let pullId = ""; | |
| 37 | ||
| 38 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 39 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 40 | return r.value; | |
| 41 | }; | |
| 42 | ||
| 43 | beforeAll(async () => { | |
| 44 | root = mkdtempSync(join(tmpdir(), "forge-feed-reply-")); | |
| 45 | app = createApp({ dataDir: root, port: 0, disk: () => ({ freeBytes: 95, totalBytes: 100 }) }); | |
| 46 | servers = await serve(app); | |
| 47 | const person = async (username: string, first: boolean) => | |
| 48 | must( | |
| 49 | await createUser(app.db, { | |
| 50 | origin: "seed", | |
| 51 | trusted: true, | |
| 52 | allowReserved: true, | |
| 53 | username, | |
| 54 | email: `${username}@forge.local`, | |
| 55 | password: "a-good-passphrase", | |
| 56 | adminIfFirst: first, | |
| 57 | }), | |
| 58 | ); | |
| 59 | await person("root", true); | |
| 60 | const alice = await person("alice", false); | |
| 61 | const bob = await person("bob", false); | |
| 62 | promote(app.db, alice, bob); | |
| 63 | aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 64 | bobCookie = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 65 | for (const [name, visibility] of [["open", "public"], ["secret", "private"]] as const) { | |
| 66 | const repo = must(await createRepo(app, alice, { name, visibility })); | |
| 67 | must( | |
| 68 | await commitToBranch(app, alice, repo, { | |
| 69 | message: "seed", | |
| 70 | files: [{ path: "README.md", content: `# ${name}\n` }], | |
| 71 | }), | |
| 72 | ); | |
| 73 | const issue = must(openIssue(app, alice, repo, { title: `Question for ${name}`, body: "why?" })); | |
| 74 | if (name === "open") { | |
| 75 | must( | |
| 76 | await commitToBranch(app, alice, repo, { | |
| 77 | branch: "idea", | |
| 78 | base: "main", | |
| 79 | message: "an idea", | |
| 80 | files: [{ path: "idea.md", content: "idea\n" }], | |
| 81 | }), | |
| 82 | ); | |
| 83 | const pull = must( | |
| 84 | await openPull(app, alice, repo, { title: "An idea", body: "", baseBranch: "main", headBranch: "idea" }), | |
| 85 | ); | |
| 86 | pullNumber = pull.number; | |
| 87 | pullId = pull.id; | |
| 88 | publicIssue = issue.number; | |
| 89 | publicId = issue.id; | |
| 90 | } else { | |
| 91 | secretIssue = issue.number; | |
| 92 | secretId = issue.id; | |
| 93 | } | |
| 94 | } | |
| 95 | }, 30_000); | |
| 96 | ||
| 97 | afterAll(async () => { | |
| 98 | await servers.stop(); | |
| 99 | app.close(); | |
| 100 | rmSync(root, { recursive: true, force: true }); | |
| 101 | }); | |
| 102 | ||
| 103 | const post = (path: string, cookie: string, fields: Record<string, string>) => | |
| 104 | fetch(`${servers.url}${path}`, { | |
| 105 | method: "POST", | |
| 106 | redirect: "manual", | |
| 107 | headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, | |
| 108 | body: new URLSearchParams(fields), | |
| 109 | }); | |
| 110 | ||
| 111 | test("a card about an issue offers Reply, as a plain form", async () => { | |
| 112 | const res = await fetch(`${servers.url}/feed`, { headers: { cookie: aliceCookie } }); | |
| 113 | const page = await res.text(); | |
| 114 | expect(page).toContain(`action="/alice/open/issues/${publicIssue}/comments"`); | |
| 115 | expect(page).toContain('name="back" value="feed"'); | |
| 116 | }); | |
| 117 | ||
| 118 | test("a reply posts the comment and returns to the feed with a flash", async () => { | |
| 119 | const res = await post(`/alice/open/issues/${publicIssue}/comments`, aliceCookie, { | |
| 120 | body: "On it.", | |
| 121 | back: "feed", | |
| 122 | }); | |
| 123 | expect(res.status).toBe(303); | |
| 124 | expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe("/feed"); | |
| 125 | expect(listComments(app.db, publicId).map((c) => c.body)).toContain("On it."); | |
| 126 | }); | |
| 127 | ||
| 128 | const flashOf = (res: Response): { message?: string; tone?: string } => { | |
| 129 | const cookie = res.headers.getSetCookie().find((c) => c.startsWith("forge_flash=")) ?? ""; | |
| 130 | return JSON.parse(decodeURIComponent(cookie.slice("forge_flash=".length).split(";")[0]!)); | |
| 131 | }; | |
| 132 | ||
| 133 | test("a reader of a repository they cannot see is refused", async () => { | |
| 134 | const res = await post(`/alice/secret/issues/${secretIssue}/comments`, bobCookie, { | |
| 135 | body: "Let me in.", | |
| 136 | back: "feed", | |
| 137 | }); | |
| 138 | expect(res.status).toBe(404); | |
| 139 | expect(listComments(app.db, secretId)).toHaveLength(0); | |
| 140 | }); | |
| 141 | ||
| 142 | test("Reply is offered only to a signed-in viewer who may comment there", () => { | |
| 143 | const db = app.db; | |
| 144 | const bob = db.query("SELECT * FROM user WHERE username = 'bob'").get() as never; | |
| 145 | expect(replyRepo(db, bob, "alice/open")).toBeDefined(); | |
| 146 | expect(replyRepo(db, bob, "alice/secret")).toBeUndefined(); | |
| 147 | expect(replyRepo(db, undefined, "alice/open")).toBeUndefined(); | |
| 148 | expect(replyRepo(db, bob, "alice/missing")).toBeUndefined(); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a card drawn for a viewer who may not reply has no Reply form", () => { | |
| 152 | const item: FeedItem = { | |
| 153 | id: "x", | |
| 154 | kind: "tell", | |
| 155 | app: "alice/open", | |
| 156 | actor: "alice", | |
| 157 | at: new Date().toISOString(), | |
| 158 | link: `/alice/open/issues/${publicIssue}`, | |
| 159 | what: "opened an issue: Question", | |
| 160 | reason: "", | |
| 161 | score: 1, | |
| 162 | } as FeedItem; | |
| 163 | expect(String(feedCard(item, new Map(), undefined, false))).not.toContain("/comments"); | |
| 164 | expect(String(feedCard(item, new Map(), undefined, true))).toContain(`/alice/open/issues/${publicIssue}/comments`); | |
| 165 | }); | |
| 166 | ||
| 167 | const cardOf = (over: Partial<FeedItem>): string => | |
| 168 | String( | |
| 169 | feedCard( | |
| 170 | { | |
| 171 | id: "y", | |
| 172 | kind: "tell", | |
| 173 | app: "alice/open", | |
| 174 | actor: "alice", | |
| 175 | at: new Date().toISOString(), | |
| 176 | link: `/alice/open/issues/${publicIssue}`, | |
| 177 | what: "opened an issue", | |
| 178 | reason: "", | |
| 179 | score: 1, | |
| 180 | ...over, | |
| 181 | } as FeedItem, | |
| 182 | new Map(), | |
| 183 | undefined, | |
| 184 | true, | |
| 185 | ), | |
| 186 | ); | |
| 187 | ||
| 188 | test("the Reply form posts back=feed, and its label escapes a hostile title", () => { | |
| 189 | const card = cardOf({ what: 'opened an issue: "><script>alert(1)</script>' }); | |
| 190 | const form = card.slice(card.indexOf(`action="/alice/open/issues/${publicIssue}/comments"`)); | |
| 191 | expect(form).toContain('name="back" value="feed"'); | |
| 192 | expect(form).toContain('name="body"'); | |
| 193 | expect(card).not.toContain("<script>alert(1)</script>"); | |
| 194 | expect(card).toContain("<script>"); | |
| 195 | }); | |
| 196 | ||
| 197 | test("one primary button per card: Reply is the accent, Tell is not", () => { | |
| 198 | const card = cardOf({}); | |
| 199 | expect(card.match(/data-variant="primary"/g)).toHaveLength(1); | |
| 200 | const told = cardOf({ link: "/alice/open" }); | |
| 201 | expect(told).not.toContain("/comments"); | |
| 202 | expect(told.match(/data-variant="primary"/g)).toHaveLength(1); | |
| 203 | }); | |
| 204 | ||
| 205 | test("a post linking to a thread in another repository offers no Reply", () => { | |
| 206 | expect(cardOf({ app: "alice/secret" })).not.toContain("/comments"); | |
| 207 | }); | |
| 208 | ||
| 209 | test("an empty reply on an issue returns to the feed with a danger flash", async () => { | |
| 210 | const res = await post(`/alice/open/issues/${publicIssue}/comments`, aliceCookie, { body: " ", back: "feed" }); | |
| 211 | expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe("/feed"); | |
| 212 | expect(flashOf(res).tone).toBe("danger"); | |
| 213 | }); | |
| 214 | ||
| 215 | test("an empty comment from the issue page stays on it", async () => { | |
| 216 | const res = await post(`/alice/open/issues/${publicIssue}/comments`, aliceCookie, { body: " " }); | |
| 217 | expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe(`/alice/open/issues/${publicIssue}`); | |
| 218 | }); | |
| 219 | ||
| 220 | test("a signed-out visitor to the feed sees no Reply form", async () => { | |
| 221 | const page = await (await fetch(`${servers.url}/feed`, { redirect: "manual" })).text(); | |
| 222 | expect(page).not.toContain('name="back" value="feed"'); | |
| 223 | }); | |
| 224 | ||
| 225 | test("a reader of a public repository sees the card and may reply", async () => { | |
| 226 | const page = await (await fetch(`${servers.url}/feed`, { headers: { cookie: bobCookie } })).text(); | |
| 227 | expect(page).toContain(`action="/alice/open/issues/${publicIssue}/comments"`); | |
| 228 | expect(page).not.toContain("/alice/secret/issues"); | |
| 229 | }); | |
| 230 | ||
| 231 | test("a reply on a pull comments on it and returns to the feed with a flash", async () => { | |
| 232 | const res = await post(`/alice/open/pulls/${pullNumber}/comments`, aliceCookie, { body: "Looks good.", back: "feed" }); | |
| 233 | expect(res.status).toBe(303); | |
| 234 | expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe("/feed"); | |
| 235 | expect(flashOf(res).message).toBe("Reply posted."); | |
| 236 | expect(listComments(app.db, pullId).map((c) => c.body)).toContain("Looks good."); | |
| 237 | }); | |
| 238 | ||
| 239 | test("an empty reply on a pull returns to the feed with a danger flash", async () => { | |
| 240 | const res = await post(`/alice/open/pulls/${pullNumber}/comments`, aliceCookie, { body: " ", back: "feed" }); | |
| 241 | expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe("/feed"); | |
| 242 | expect(flashOf(res).tone).toBe("danger"); | |
| 243 | }); | |
| 244 | ||
| 245 | test("a pull comment with an unknown back returns to the pull page", async () => { | |
| 246 | for (const back of ["https://evil.example/", "constructor", ""]) { | |
| 247 | const res = await post(`/alice/open/pulls/${pullNumber}/comments`, aliceCookie, { body: "Fine.", back }); | |
| 248 | expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe(`/alice/open/pulls/${pullNumber}`); | |
| 249 | } | |
| 250 | }); | |
| 251 | ||
| 252 | test("the issue reply flashes Reply posted.", async () => { | |
| 253 | const res = await post(`/alice/open/issues/${publicIssue}/comments`, aliceCookie, { body: "Noted.", back: "feed" }); | |
| 254 | expect(flashOf(res).message).toBe("Reply posted."); | |
| 255 | }); | |
| 256 | ||
| 257 | test("a back that is not the feed's word goes nowhere new", async () => { | |
| 258 | const res = await post(`/alice/open/issues/${publicIssue}/comments`, aliceCookie, { | |
| 259 | body: "Again.", | |
| 260 | back: "constructor", | |
| 261 | }); | |
| 262 | expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe(`/alice/open/issues/${publicIssue}`); | |
| 263 | }); |
test/palette-roster.test.ts +3
| @@ -110,6 +110,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 110 | 110 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 111 | 111 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 112 | 112 | ["domains", "Failure domains", "failure domains and their last drill"], |
| 113 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull"], | |
| 113 | 114 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 114 | 115 | ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"], |
| 115 | 116 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| @@ -159,6 +160,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 159 | 160 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 160 | 161 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 161 | 162 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 163 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull"], | |
| 162 | 164 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 163 | 165 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 164 | 166 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| @@ -208,6 +210,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 208 | 210 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 209 | 211 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 210 | 212 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 213 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull"], | |
| 211 | 214 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 212 | 215 | ["profile", "Your profile", "display name and email"], |
| 213 | 216 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
test/tui-feed-reply.test.ts added +150
| @@ -0,0 +1,150 @@ | ||
| 1 | /** | |
| 2 | * The feed in the terminal: `r` on a post about an issue replies through the | |
| 3 | * same comment action as the page's Reply; a person who cannot read the | |
| 4 | * repository has no such post and no way to reply. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { openIssue } from "../src/actions/issues.ts"; | |
| 11 | import { openPull } from "../src/actions/pulls.ts"; | |
| 12 | import { readFeed } from "../src/feed/feed.ts"; | |
| 13 | import { replyTarget } from "../src/feed/reply.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { listComments } from "../src/core/issues.ts"; | |
| 17 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 18 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { promote } from "./helpers/members.ts"; | |
| 22 | import { settleApp } from "./helpers/tui.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | let dir = ""; | |
| 27 | let forge: App; | |
| 28 | let issueId = ""; | |
| 29 | let pullId = ""; | |
| 30 | let pullNumber = 0; | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dir = await mkdtemp(join(tmpdir(), "forge-tui-feed-reply-")); | |
| 34 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 35 | for (const [username, first] of [["root", true], ["alice", false], ["bob", false]] as const) { | |
| 36 | const made = await createUser(forge.db, { | |
| 37 | origin: "seed", trusted: true, allowReserved: true, username, email: `${username}@forge.local`, | |
| 38 | password: "a-good-passphrase", adminIfFirst: first, | |
| 39 | }); | |
| 40 | if (!made.ok) throw new Error(made.error); | |
| 41 | promote(forge.db, made.value); | |
| 42 | } | |
| 43 | const alice = getUserByName(forge.db, "alice")!; | |
| 44 | const repo = await createRepo(forge, alice, { name: "secret", visibility: "private" }); | |
| 45 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 46 | await commitToBranch(forge, alice, repo.value, { message: "seed", files: [{ path: "README.md", content: "# s\n" }] }); | |
| 47 | const issue = openIssue(forge, alice, repo.value, { title: "Hidden question", body: "why?" }); | |
| 48 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 49 | issueId = issue.value.id; | |
| 50 | const open = await createRepo(forge, alice, { name: "open", visibility: "public" }); | |
| 51 | if (!open.ok) throw new Error(String(open.error)); | |
| 52 | await commitToBranch(forge, alice, open.value, { message: "seed", files: [{ path: "README.md", content: "# o\n" }] }); | |
| 53 | await commitToBranch(forge, alice, open.value, { | |
| 54 | branch: "idea", base: "main", message: "an idea", files: [{ path: "idea.md", content: "idea\n" }], | |
| 55 | }); | |
| 56 | const pull = await openPull(forge, alice, open.value, { title: "An idea", body: "", baseBranch: "main", headBranch: "idea" }); | |
| 57 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 58 | pullId = pull.value.id; | |
| 59 | pullNumber = pull.value.number; | |
| 60 | }); | |
| 61 | ||
| 62 | /** Moves the cursor onto the post linking to `link` in this person's feed. */ | |
| 63 | const downTo = (username: string, link: string): string[] => { | |
| 64 | const items = readFeed(forge.db, getUserByName(forge.db, username)!, { limit: 20 }).items; | |
| 65 | const at = items.findIndex((i) => i.link === link); | |
| 66 | if (at < 0) throw new Error(`no post for ${link}`); | |
| 67 | return Array.from({ length: at }, () => "j"); | |
| 68 | }; | |
| 69 | ||
| 70 | afterAll(async () => { | |
| 71 | forge.db.close(); | |
| 72 | await rm(dir, { recursive: true, force: true }); | |
| 73 | }); | |
| 74 | ||
| 75 | function boot(username: string) { | |
| 76 | const terminal = headlessTerminal(140, 32); | |
| 77 | const identity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, username)! }; | |
| 78 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 79 | app.start(); | |
| 80 | const press = async (...keys: string[]) => { | |
| 81 | for (const k of keys) app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 82 | await settleApp(app); | |
| 83 | }; | |
| 84 | const open = async () => { | |
| 85 | app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
outdated
Bob never sees this post, so the test passes with or without the gate. Test the gate where the post is present (replyRepo/replyTarget), and add a pull-post reply and an empty-body case. |
||
| 86 | await settleApp(app); | |
| 87 | await press(..."Feed", "enter"); | |
| 88 | }; | |
| 89 | const submit = async () => { | |
| 90 | app.dispatch({ type: "key", key: "d", ctrl: true, alt: false, shift: false }); | |
| 91 | await settleApp(app); | |
| 92 | }; | |
| 93 | return { text: () => app.screen.toText(), press, open, submit, stop: () => app.stop() }; | |
| 94 | } | |
| 95 | ||
| 96 | test("r on a post about an issue posts a comment on it", async () => { | |
| 97 | const t = boot("alice"); | |
| 98 | await t.open(); | |
| 99 | expect(t.text()).toContain("Hidden question"); | |
| 100 | await t.press("r"); | |
| 101 | await t.press(..."Taking it."); | |
| 102 | await t.submit(); | |
| 103 | expect(listComments(forge.db, issueId).map((c) => c.body)).toContain("Taking it."); | |
| 104 | t.stop(); | |
| 105 | }); | |
| 106 | ||
| 107 | test("r on a post about a pull posts a comment on it", async () => { | |
| 108 | const t = boot("alice"); | |
| 109 | await t.open(); | |
| 110 | await t.press(...downTo("alice", `/alice/open/pulls/${pullNumber}`)); | |
| 111 | await t.press("r"); | |
| 112 | expect(t.text()).toContain(`Reply on alice/open #${pullNumber}`); | |
| 113 | await t.press(..."Merging soon."); | |
| 114 | await t.submit(); | |
| 115 | expect(listComments(forge.db, pullId).map((c) => c.body)).toContain("Merging soon."); | |
| 116 | t.stop(); | |
| 117 | }); | |
| 118 | ||
| 119 | test("an empty reply says so and posts nothing", async () => { | |
| 120 | const before = listComments(forge.db, pullId).length; | |
| 121 | const t = boot("alice"); | |
| 122 | await t.open(); | |
| 123 | await t.press(...downTo("alice", `/alice/open/pulls/${pullNumber}`)); | |
| 124 | await t.press("r"); | |
| 125 | await t.submit(); | |
| 126 | expect(t.text()).toContain("write something first"); | |
| 127 | expect(listComments(forge.db, pullId)).toHaveLength(before); | |
| 128 | t.stop(); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a post about a thread the person cannot read has no reply target, even when present", () => { | |
| 132 | const db = forge.db; | |
| 133 | const post = { app: "alice/secret", link: "/alice/secret/issues/1" }; | |
| 134 | expect(replyTarget(db, getUserByName(db, "bob")!, post)).toBeUndefined(); | |
| 135 | expect(replyTarget(db, undefined, post)).toBeUndefined(); | |
| 136 | expect(replyTarget(db, getUserByName(db, "alice")!, post)?.thread.number).toBe(1); | |
| 137 | // A link into another repository than the post's own is no target either. | |
| 138 | expect(replyTarget(db, getUserByName(db, "bob")!, { app: "alice/open", link: "/alice/secret/issues/1" })).toBeUndefined(); | |
| 139 | }); | |
| 140 | ||
| 141 | test("a person who cannot read the repository is not shown its post", async () => { | |
| 142 | const t = boot("bob"); | |
| 143 | await t.open(); | |
| 144 | expect(t.text()).not.toContain("Hidden question"); | |
| 145 | await t.press("r"); | |
| 146 | await t.press(..."Let me in."); | |
| 147 | await t.submit(); | |
| 148 | expect(listComments(forge.db, issueId).map((c) => c.body)).not.toContain("Let me in."); | |
| 149 | t.stop(); | |
| 150 | }); |
new above
offline
data-reply is dead markup; remove. Also the Reply button here is primary and the Tell/Steer button below is primary too: two accents in one card. Make Tell/Steer non-primary when Reply is shown.