Public: anyone can read this; the crew can change it.

A like is a signal in the decision ledger, reachable the same way for a person and an agent, and a test reads it back #10410

Merged opened this

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

Merged

scout/forge:scout/10389-a-like-is-a-signal-in-the-decision-ledge → main 9 commits fork 3 threads · 3 unresolved

  • Merged. scout/10389-a-like-is-a-signal-in-the-decision-ledge was merged into main as 20b7a27.

Review

Sign in to review this change.
20 files changed +464 −54
docs/FEED.md +8 −2
@@ -175,6 +175,12 @@ lean client home has no feed script, so no draft.
175175
176176 ## Likes
177177
178 A like is a signal (#7751): `POST /api/v1/feed/interactions` with `kind: "liked"` (or the form at `POST /feed/like`) toggles the viewer's `feed_like` row: pressing again deletes it, so a person counts once per item. An item of `GET /api/v1/feed` carries `likes` and `liked` once anyone likes it (absent means 0 and false), and every card draws a Like button with the count. A like does not move the ranking. An item the viewer cannot read is a 404, as for every act.
178 A like is a signal written to the decision ledger (#7751, #10389), the same for a person and an agent. Every like or unlike is one row of `feed_signal` (who, what, when; `seq` orders them), never edited and never deleted. Taking a like back is a new `unlike` row; asking for the state already held (a second like) writes nothing and answers the same. `feed_like` is the current state, kept in step in the same transaction (`setLike` in `src/feed/likes.ts`), so a post's `likes` equals `ledgerCount` for it; `test/feed-like.test.ts` asserts that after a like, an unlike and a repeat.
179179
180 In the terminal, the Feed screen (`ssh` in, then Ctrl-K and "Feed") marks a liked post `♥ n` and one others like `♡ n`; `l` on the post under the cursor likes it and a second `l` takes it back. It runs the same `readInteraction` check and `toggleLike` as the page, and the write moves the forge's write clock, so web viewers see it on their next live refresh.
180 Doors, all through `setLike` and the `readInteraction` check: the Like button (`POST /feed/like`, a same-origin form carrying `act` = `like` or `unlike`), `POST /api/v1/feed/interactions` with `kind: "liked"` or `"unliked"`, and the MCP tool `feed_like` (`{ item, act: like | unlike | history }`). A write needs a session with a same-origin POST, or a token with `rw` scope; an anonymous caller gets 401 naming `/signin`; a token meets the ordinary per-token rate limit, so an agent cannot flood likes.
181
182 To read it back: `GET /api/v1/feed/likes?item=event:…` (or `feed_like` with `act: "history"`) answers `{ item, ledger: [{ seq, who, act, at }] }`, newest first, 50 a page, for any item the caller may read (`more: true` says older rows remain; `&before=<seq of the last row>` reads them); or `SELECT * FROM feed_signal WHERE item_id = ?`. An item of `GET /api/v1/feed` carries `likes` and `liked` once anyone likes it (absent means 0 and false), and every card draws a Like button with the count. A like does not move the ranking. An item the viewer cannot read is a 404, as for every act.
183
184 In the terminal, the Feed screen (`ssh` in, then Ctrl-K and "Feed") marks a liked post `♥ n` and one others like `♡ n`; `l` on the post under the cursor likes it and a second `l` takes it back. It runs the same `readInteraction` check as the page and writes through `setLike` (`toggleLike` only picks the act from the current state), and the write moves the forge's write clock, so web viewers see it on their next live refresh. `h` on a post lists its ledger there (who, act, when, newest first; `m` for older rows), behind the same check.
outdated

Stale: the terminal and page no longer both run toggleLike; the page runs setLike. And the how-to should live in docs/features/ (finding 2).

185
186 The how-to, the decisions and what was left out: `docs/features/feed-like-ledger.md`.
docs/features/feed-like-ledger.md added +22
@@ -0,0 +1,22 @@
1 # Feed likes: the ledger
2
3 A like is a row of `feed_signal` (who, item, act, when), the same for a person and an agent. `feed_like` is the current state, moved in the same transaction by `setLike` (`src/feed/likes.ts`).
4
5 ## Use it
6
7 - Page: the Like button on a card (`POST /feed/like`, `act` = `like` | `unlike`).
8 - API: `POST /api/v1/feed/interactions` with `kind: "liked"` or `"unliked"` (`rw` token).
9 - MCP: `feed_like` with `act` = `like` | `unlike` | `history`.
10 - Terminal: Feed screen (Ctrl-K, "Feed"): `l` likes or takes back, `h` lists the post's ledger, `m` reads older rows, `esc` closes.
11 - Read it back: `GET /api/v1/feed/likes?item=event:…[&before=seq]` answers `{ item, ledger: [{ seq, who, act, at }], more }`, newest first, 50 a page. Pass the last row's `seq` as `before` while `more` is true. MCP `history` takes the same `before`.
12
13 ## Decisions
14
15 - Asking for the state already held writes nothing. Unlike is a new row; nothing is edited or deleted.
16 - Flipping like/unlike grows the ledger, one row a flip. The ordinary per-token rate limit bounds the rate, and reading is paged, so a busy item never hides its recent acts.
17 - **Every liker's username is shown to anyone who may read the item, on purpose.** A like is a public act on a post, as a comment is. An item the caller cannot read is a 404.
18 - Every door runs the same `readInteraction` check; anonymous callers get 401 naming `/signin`.
19
20 ## Left out
21
22 Pruning old ledger rows, and hiding likers from other readers.
scripts/unreached-allow.json +1
@@ -15,6 +15,7 @@
1515 "src/flow/review_rounds.ts",
1616 "src/flow/shared_red.ts",
1717 "src/flow/stale_fold.ts",
18 "src/kernel/why_now.ts",
1819 "src/spawn/pod.ts",
1920 "src/tui/gallery.ts",
2021 "src/tui/ui/qr.ts",
src/contract/openapi/routes/getApiV1FeedLikes.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/feed/likes",
6 summary: "The like ledger of one feed item, newest first, 50 a page (ro scope): `?item=` is a feed item id, `app` (owner/name) names the repository when the item is not an event; `before` is a seq to read the rows older than; answers { item, ledger: [{ seq, who, act, at }], more }, `more` true when older rows remain, `act` being like or unlike; an anonymous caller is told to sign in at /signin",
7 auth: true,
8 query: { item: "string", app: "string", before: "string" },
9 };
src/contract/openapi/routes/index.ts +1
@@ -76,6 +76,7 @@ export { route as getApiV1FeaturesSlug } from "./getApiV1FeaturesSlug.ts";
7676 export { route as getApiV1FeaturesSlugFeed } from "./getApiV1FeaturesSlugFeed.ts";
7777 export { route as getApiV1Feed } from "./getApiV1Feed.ts";
7878 export { route as getApiV1FeedChips } from "./getApiV1FeedChips.ts";
79 export { route as getApiV1FeedLikes } from "./getApiV1FeedLikes.ts";
7980 export { route as getApiV1Flags } from "./getApiV1Flags.ts";
8081 export { route as getApiV1FleetGraph } from "./getApiV1FleetGraph.ts";
8182 export { route as getApiV1FleetOps } from "./getApiV1FleetOps.ts";
src/contract/openapi/routes/postApiV1FeedInteractions.ts +1 −1
@@ -3,7 +3,7 @@ import type { Route } from "../shared.ts";
33 export const route: Route = {
44 method: "post",
55 path: "/api/v1/feed/interactions",
6 summary: "Record an act on a feed item, so the feed learns what the caller wants more or less of (rw scope); `item` is a feed item id, `kind` one the feed knows, `app` is owner/name and names the repository when the item is not an event; `liked` is a toggle, a second like takes it back, and answers 201 with { likes, liked }; any other answers 201 with { id }",
6 summary: "Record an act on a feed item, so the feed learns what the caller wants more or less of (rw scope); `item` is a feed item id, `kind` one the feed knows, `app` is owner/name and names the repository when the item is not an event; `liked` and `unliked` each write one row to the like ledger and are idempotent (a repeat writes nothing and answers the same), answering 201 with { likes, liked }; any other answers 201 with { id }",
77 auth: true,
88 status: 201,
99 body: { item: "string", kind: "string", app: "string" },
src/core/migration-pins/20261109200000-feed-like-ledger.pin added +1
@@ -0,0 +1,1 @@
1 2edcf8a2d2dd0656fd8a312bb0aa1aa8047d16c7984c4cccd6ed6e8073c7dd48
src/core/migrations/20261109200000-feed-like-ledger.ts added +18
@@ -0,0 +1,18 @@
1 // A like is a signal in the decision ledger (#10389): one row per act, never
2 // edited. `act` is `like` or `unlike`; taking a like back is a new row, not a
3 // deletion of history. `feed_like` stays the current state (kept in step in
4 // the same transaction) so counting a post's likes reads no history. Rows
5 // that already stand in `feed_like` become the first `like` rows. Additive.
6 export default `
7 CREATE TABLE feed_signal (
8 seq INTEGER PRIMARY KEY AUTOINCREMENT,
9 viewer_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
10 item_id TEXT NOT NULL,
11 repo_id TEXT REFERENCES repo(id) ON DELETE CASCADE,
12 act TEXT NOT NULL CHECK (act IN ('like','unlike')),
13 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
14 );
15 CREATE INDEX feed_signal_item ON feed_signal(item_id, seq);
16 INSERT INTO feed_signal (viewer_id, item_id, repo_id, act, created_at)
17 SELECT viewer_id, item_id, repo_id, 'like', created_at FROM feed_like ORDER BY created_at;
18 `;
src/feed/likes.ts +76 −7
@@ -1,6 +1,6 @@
11 /**
2 * Likes on feed items. A like is a `feed_like` row, one per person and item;
3 * pressing again deletes it, so a person counts once however often they press.
2 * Likes on feed items. `feed_like` is the current state, one row per person and item;
3 * each act is also a ledger row (`feed_signal`), so a person counts once however often they press.
44 */
55
66 import type { Database } from "bun:sqlite";
@@ -28,10 +28,79 @@ export function likesOf(
2828 return out;
2929 }
3030
31 /** The like pressed: liked becomes unliked and the other way round. Answers the state after. */
32 export function toggleLike(db: Database, viewerId: string, itemId: string, repoId: string): Likes {
33 const gone = db.run("DELETE FROM feed_like WHERE viewer_id = ?1 AND item_id = ?2", [viewerId, itemId]);
34 if (gone.changes === 0)
35 db.run("INSERT INTO feed_like (viewer_id, item_id, repo_id) VALUES (?1, ?2, ?3)", [viewerId, itemId, repoId]);
31 export type LikeAct = "like" | "unlike";
32
33 /**
34 * Like or unlike, as one ledger row (`feed_signal`: who, what, when). Asking
35 * for the state already held is a no-op that answers the same. The current
36 * state (`feed_like`) moves in the same transaction; history is only added to.
37 */
38 export function setLike(
39 db: Database,
40 viewerId: string,
41 itemId: string,
42 repoId: string,
43 act: LikeAct,
44 ): Likes {
45 db.transaction(() => {
46 const has = db.query("SELECT 1 FROM feed_like WHERE viewer_id = ?1 AND item_id = ?2").get(viewerId, itemId) !== null;
47 if (has === (act === "like")) return false;
48 db.run("INSERT INTO feed_signal (viewer_id, item_id, repo_id, act) VALUES (?1, ?2, ?3, ?4)", [viewerId, itemId, repoId, act]);
49 if (act === "like") db.run("INSERT INTO feed_like (viewer_id, item_id, repo_id) VALUES (?1, ?2, ?3)", [viewerId, itemId, repoId]);
50 else db.run("DELETE FROM feed_like WHERE viewer_id = ?1 AND item_id = ?2", [viewerId, itemId]);
51 return true;
52 })();
3653 return likesOf(db, viewerId, [itemId]).get(itemId)!;
3754 }
55
56 /** The like pressed in the terminal: liked becomes unliked and the other way round. */
57 export function toggleLike(db: Database, viewerId: string, itemId: string, repoId: string): Likes {
58 const liked = likesOf(db, viewerId, [itemId]).get(itemId)!.liked;
59 return setLike(db, viewerId, itemId, repoId, liked ? "unlike" : "like");
60 }
61
62 export interface LedgerRow {
63 readonly seq: number;
64 readonly who: string;
65 readonly item: string;
66 readonly act: LikeAct;
67 readonly at: string;
68 }
69
70 export const LEDGER_PAGE = 50;
71
72 export interface LedgerPage {
73 readonly rows: LedgerRow[];
74 /** True when older rows remain: ask again with `before` = the last row's `seq`. */
75 readonly more: boolean;
76 }
77
78 /** One item's ledger, newest first, a page at a time; `before` is a `seq` to read the rows older than. */
79 export function likeLedger(db: Database, itemId: string, before?: number, limit = LEDGER_PAGE): LedgerPage {
80 const rows = db
81 .query<LedgerRow, [string, number, number]>(
82 `SELECT s.seq, u.username AS who, s.item_id AS item, s.act, s.created_at AS at
83 FROM feed_signal s JOIN user u ON u.id = s.viewer_id
84 WHERE s.item_id = ?1 AND s.seq < ?2 ORDER BY s.seq DESC LIMIT ?3`,
85 )
86 .all(itemId, before ?? Number.MAX_SAFE_INTEGER, limit + 1);
87 return { rows: rows.slice(0, limit), more: rows.length > limit };
88 }
89
90 /** `before` from a query string or tool argument: a positive whole number, or undefined when absent; null when bad. */
91 export function parseLedgerBefore(raw: unknown): number | undefined | null {
92 if (raw === undefined || raw === null || raw === "") return undefined;
93 const n = typeof raw === "number" ? raw : /^\d{1,15}$/.test(String(raw)) ? Number(raw) : NaN;
outdated

Oldest 200 only, with no cursor: the newest acts of a busy item are unreachable. Page it or return newest first (finding 3).

94 return Number.isSafeInteger(n) && n > 0 ? n : null;
95 }
96
97 /** What the ledger says an item's likes are: viewers whose last act on it is a like. */
98 export function ledgerCount(db: Database, itemId: string): number {
99 return db
100 .query<{ n: number }, [string]>(
101 `SELECT COUNT(*) AS n FROM feed_signal s
102 WHERE s.item_id = ?1 AND s.act = 'like'
103 AND s.seq = (SELECT MAX(seq) FROM feed_signal WHERE item_id = s.item_id AND viewer_id = s.viewer_id)`,
104 )
105 .get(itemId)!.n;
106 }
src/http/feed.ts +33 −5
@@ -13,7 +13,7 @@ import {
1313 import { parseFrom, TELL_TEXT_MAX } from "../actions/tell_from.ts";
1414 import { DIRECTION_NOTE } from "../crew/direct.ts";
1515 import { readInteraction } from "../feed/interact.ts";
16 import { toggleLike } from "../feed/likes.ts";
16 import { likeLedger, parseLedgerBefore, setLike } from "../feed/likes.ts";
1717 import {
1818 ITEM_ID,
1919 INTERACTION_KINDS,
@@ -59,8 +59,12 @@ import {
5959 unauthorized,
6060 } from "./respond.ts";
6161
62 /** What a card may report: the interaction kinds, and a like (a toggle, not a row of its own). */
63 const DOOR_KINDS = [...INTERACTION_KINDS, "liked" as const];
62 /** What a card may report: the interaction kinds, and a like or its taking back (ledger rows, `feed/likes.ts`). */
63 const DOOR_KINDS = [...INTERACTION_KINDS, "liked" as const, "unliked" as const];
64
65 /** An anonymous caller is told where to sign in, or to send a token. */
66 const signInFirst401 = () =>
67 apiError("unauthorized", 401, "sign in at /signin, or send a bearer token with rw scope");
6468
6569 export function feedRoutes(router: Router<Handler>): void {
6670 router.add("GET", "/api/v1/feed", (ctx) => {
@@ -258,6 +262,7 @@ export function feedRoutes(router: Router<Handler>): void {
258262 });
259263
260264 router.add("POST", "/api/v1/feed/interactions", async (ctx) => {
265 if (!ctx.auth.user) return signInFirst401();
261266 const denied = requireScope(ctx, "rw");
262267 if (denied) return denied;
263268 const viewer = ctx.auth.user!;
@@ -267,8 +272,11 @@ export function feedRoutes(router: Router<Handler>): void {
267272 if (!act.ok)
268273 return apiError(act.error.code, act.error.code === "not_found" ? 404 : 422, act.error.message);
269274 const { itemId, kind, target } = act.value;
270 // A like is a toggle: the second press takes it back (`feed/likes.ts`).
271 if (kind === "liked") return json(toggleLike(db, viewer.id, itemId, target.repoId), 201);
275 // A like and its taking back are ledger rows; asking again for what stands is a no-op.
276 if (kind === "liked" || kind === "unliked") {
277 const now = setLike(db, viewer.id, itemId, target.repoId, kind === "liked" ? "like" : "unlike");
278 return json(now, 201);
279 }
272280 const made = recordInteraction(db, {
273281 viewerId: viewer.id,
274282 itemId,
outdated

Lists every liker's username to any reader of the item. Make it deliberate and documented, or narrow it (finding 4).

@@ -279,6 +287,26 @@ export function feedRoutes(router: Router<Handler>): void {
279287 return json({ id: made.id }, 201);
280288 });
281289
290 // The ledger of one item's likes and unlikes, newest first, 50 a page: `?item=event:…[&app=owner/name][&before=seq]`.
291 // Every liker's username is shown to anyone who may read the item, on purpose: a like is a public act
292 // on a post, as a comment is (docs/features/feed-like-ledger.md).
293 router.add("GET", "/api/v1/feed/likes", (ctx) => {
294 if (!ctx.auth.user) return signInFirst401();
295 const viewer = ctx.auth.user;
296 const act = readInteraction(
297 ctx.app.db,
298 viewer,
299 { item: ctx.url.searchParams.get("item"), kind: "liked", app: ctx.url.searchParams.get("app") },
300 ["liked"],
301 );
302 if (!act.ok)
303 return apiError(act.error.code, act.error.code === "not_found" ? 404 : 422, act.error.message);
304 const before = parseLedgerBefore(ctx.url.searchParams.get("before"));
305 if (before === null) return apiError("invalid_before", 422, "before is a ledger seq, a positive whole number");
306 const page = likeLedger(ctx.app.db, act.value.itemId, before);
307 return json({ item: act.value.itemId, ledger: page.rows, more: page.more });
308 });
309
282310 // A tell from a card, as JSON, so the outbox can replay it by key.
283311 router.add("POST", "/api/v1/feed/tells", async (ctx) => {
284312 const denied = requireScope(ctx, "rw");
src/http/mcp/tools/feed_like.ts added +37
@@ -0,0 +1,37 @@
1 import { readInteraction } from "../../../feed/interact.ts";
2 import { likeLedger, parseLedgerBefore, setLike } from "../../../feed/likes.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "feed_like",
7 doc: "`{ item, act, app?, before? }`: `act` like or unlike writes one row to the like ledger (who, what, when), as the card's Like does; liking a liked item is a no-op that answers the same; `act` history reads the item's ledger, newest first, 50 a page (`before` = a seq to read older rows; `more` says some remain); answers `{ likes, liked }` or `{ ledger: [{ seq, who, act, at }], more }`",
8 description: "Like or unlike a feed item as a ledger row, or read its ledger.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 item: { type: "string" },
13 act: { type: "string", enum: ["like", "unlike", "history"] },
14 app: { type: "string" },
15 before: { type: "number" },
16 },
17 required: ["item", "act"],
18 },
19 writes: true,
20 run(ctx, user, args) {
21 const act = args["act"];
22 if (act !== "like" && act !== "unlike" && act !== "history")
23 return bad("invalid_kind", "act is one of like, unlike, history");
24 // `kind` is the door's own for a like; the caller names `act`.
25 const checked = readInteraction(ctx.app.db, user, { ...args, kind: "liked" }, ["liked"] as const);
26 if (!checked.ok) return bad(checked.error.code, checked.error.message);
27 const { itemId, target } = checked.value;
28 if (act === "history") {
29 const before = parseLedgerBefore(args["before"]);
30 if (before === null) return bad("invalid_before", "before is a ledger seq, a positive whole number");
31 const page = likeLedger(ctx.app.db, itemId, before);
32 return good({ ledger: page.rows, more: page.more });
33 }
34 const now = setLike(ctx.app.db, user.id, itemId, target.repoId, act);
35 return good(now);
36 },
37 };
src/http/mcp/tools/index.ts +1
@@ -61,6 +61,7 @@ export { tool as features } from "./features.ts";
6161 export { tool as feed } from "./feed.ts";
6262 export { tool as feed_chips } from "./feed_chips.ts";
6363 export { tool as feed_dismiss } from "./feed_dismiss.ts";
64 export { tool as feed_like } from "./feed_like.ts";
6465 export { tool as flag_set } from "./flag_set.ts";
6566 export { tool as flag_winner } from "./flag_winner.ts";
6667 export { tool as flags } from "./flags.ts";
src/tui/screens/feed.ts +40 −3
@@ -6,7 +6,8 @@
66 * `addCommentThrough` the issue and pull screens call, offered on the same
77 * posts to the same people as the page's Reply (`feed/reply.ts`). `l` likes
88 * the post under the cursor and a second press takes it back: the page's
9 * Like, behind the same `readInteraction` check (`feed/likes.ts`). Twenty
9 * Like, behind the same `readInteraction` check (`feed/likes.ts`); `h` lists
10 * who liked or unliked it and when, newest first, behind that check too. Twenty
1011 * posts at a time; `t` switches to every post, newest first (`readTimeline`); `n` reads the next page from the cursor the last returned.
1112 */
1213
@@ -16,11 +17,13 @@ import { parseCursor, readFeed } from "../../feed/feed.ts";
1617 import { parseBefore, readTimeline, TIMELINE_START } from "../../feed/timeline.ts";
1718 import type { FeedItem } from "../../feed/rank.ts";
1819 import { readInteraction } from "../../feed/interact.ts";
19 import { toggleLike } from "../../feed/likes.ts";
20 import { likeLedger, toggleLike } from "../../feed/likes.ts";
2021 import { replyTarget } from "../../feed/reply.ts";
2122 import type { Ctx, Screen } from "../app/app.ts";
2223 import { binding } from "../app/keys.ts";
2324 import type { Session } from "../session.ts";
25 import { style } from "../term/style.ts";
26 import { truncate } from "../term/width.ts";
2427 import { fill, len, split } from "../ui/layout.ts";
2528 import { stepLine } from "../../flow/path.ts";
2629 import { emptyState } from "../ui/widgets.ts";
@@ -86,6 +89,24 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
8689 ctx.invalidate();
8790 };
8891
92 /** `h`: the like ledger of the post under the cursor (who, act, when), newest first, `m` for older rows. */
93 let history: { item: string; lines: string[]; oldest: number; more: boolean } | null = null;
94 const showHistory = (more = false): void => {
95 const item = items[state.index];
96 if (item === undefined || f.user === undefined) return;
97 const act = readInteraction(f.db, f.user, { item: item.id, kind: "liked", app: item.app }, ["liked"]);
98 if (!act.ok) {
99 ctx.toast(act.error.message, "danger");
100 return;
101 }
102 const itemId = act.value.itemId;
103 const keep = more && history !== null && history.item === itemId ? history : null;
104 const page = likeLedger(f.db, itemId, keep?.oldest);
105 const lines = [...(keep?.lines ?? []), ...page.rows.map((r) => oneLine(`${r.who} ${r.act} ${r.at}`))];
106 history = { item: itemId, lines, oldest: page.rows.at(-1)?.seq ?? keep?.oldest ?? 0, more: page.more };
107 ctx.invalidate();
108 };
109
89110 const submit = (): void => {
90111 const at = selected();
91112 if (at === undefined) return;
@@ -110,6 +131,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
110131 binding(["j", "k"], "move"),
111132 ...(selected() === undefined ? [] : [binding(["r"], "reply")]),
112133 ...(items.length === 0 ? [] : [binding(["l"], "like")]),
134 ...(items.length === 0 ? [] : [binding(["h"], "who liked")]),
113135 ...(next === null ? [] : [binding(["n"], "next page")]),
114136 binding(["t"], timeline ? "ranked" : "newest first"),
115137 binding(["esc", "q"], "back"),
@@ -121,6 +143,14 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
121143 emptyState(s, area, c, { title: "Nothing in the feed yet", body: "Posts about the apps you can see land here." });
122144 return;
123145 }
146 if (history !== null) {
147 s.text(area.x, area.y, truncate("Likes on this post, newest first", area.w), style(c.fg, c.bg), area.x + area.w);
148 const body = history.lines.length === 0 ? ["nobody has liked it yet"] : history.lines;
149 body.slice(0, Math.max(0, area.h - 3)).forEach((line, i) =>
150 s.text(area.x + 1, area.y + 2 + i, truncate(line, area.w - 1), style(c.mutedFg, c.bg), area.x + area.w));
151 s.text(area.x, area.y + area.h - 1, truncate(history.more ? "m older · esc back" : "esc back", area.w), style(c.mutedFg, c.bg), area.x + area.w);
152 return;
153 }
124154 const [list, box] = split(area, "v", [fill(1), len(composer.height())]);
125155 const rows: ListRow[] = items.map((item) => ({
126156 title: oneLine(`${item.actor ?? item.app} ${item.what}`),
@@ -139,11 +169,18 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
139169 else if (out === "cancel") composer.close();
140170 return true;
141171 }
172 if (history !== null) {
173 if (e.key === "esc" || e.key === "q") history = null;
174 else if (e.key === "m" && history.more) showHistory(true);
175 ctx.invalidate();
176 return true;
177 }
142178 if (e.key === "esc" || e.key === "q") ctx.pop();
143179 else if (e.key === "r") {
144180 const at = selected();
145181 if (at !== undefined) composer.start(`Reply on ${at.thread.owner}/${at.thread.name} #${at.thread.number}`);
146182 } else if (e.key === "l") like();
183 else if (e.key === "h") showHistory();
147184 else if (e.key === "t") {
148185 timeline = !timeline;
149186 items.length = 0;
@@ -162,7 +199,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
162199 export const entry: PaletteEntry = {
163200 id: "feed",
164201 title: "Feed",
165 hint: "what happened, ranked; r replies on a post about an issue or pull, l likes",
202 hint: "what happened, ranked; r replies on a post about an issue or pull, l likes, h who liked",
166203 offered: (session) => session.user !== undefined,
167204 run: (session, ctx) => ctx.push(feedScreen(session, ctx)),
168205 };
src/web/studio/feed.ts +4 −2
@@ -118,6 +118,7 @@ function likeBar(item: FeedItem): Html {
118118 <input type="hidden" name="item" value="${item.id}" />
119119 <input type="hidden" name="app" value="${item.app}" />
120120 <input type="hidden" name="back" value="/feed" />
121 <input type="hidden" name="act" value="${liked ? "unlike" : "like"}" />
121122 <button type="submit" class="btn" data-variant="ghost" data-size="sm" aria-pressed="${liked ? "true" : "false"}">Like <span data-likes>${likes}</span></button>
122123 </form>`;
123124 }
@@ -613,10 +614,11 @@ ${dayKeepJs}
613614 e.preventDefault();
614615 var was = btn.getAttribute('aria-pressed') === 'true';
615616 var n = Number(count.textContent) || 0;
616 var show = function (on, total) { btn.setAttribute('aria-pressed', on ? 'true' : 'false'); count.textContent = String(total); };
617 var show = function (on, total) { var a = form.querySelector('[name=act]'); if (a) a.value = on ? 'unlike' : 'like'; btn.setAttribute('aria-pressed', on ? 'true' : 'false'); count.textContent = String(total); };
618 var body = new URLSearchParams(new FormData(form)).toString();
617619 show(!was, Math.max(0, n + (was ? -1 : 1)));
618620 fetch(form.getAttribute('action'), { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
619 body: new URLSearchParams(new FormData(form)).toString() })
621 body: body })
620622 .then(function (r) { if (!r.ok) throw r; return r.json(); })
621623 .then(function (a) { show(a.liked, a.likes); })
622624 .catch(function () { show(was, n); problem = 'The like was not saved. Check your connection and press Like again.'; paint(); });
src/web/studio/like.ts +4 −2
@@ -5,7 +5,7 @@
55 */
66
77 import { readInteraction } from "../../feed/interact.ts";
8 import { toggleLike } from "../../feed/likes.ts";
8 import { setLike } from "../../feed/likes.ts";
99 import type { Ctx } from "../../http/context.ts";
1010 import { notFoundPage, signInFirst } from "../chrome.ts";
1111 import { backTo, field, readForm } from "../form.ts";
@@ -23,6 +23,8 @@ export async function likeSubmit(ctx: Ctx): Promise<Response> {
2323 ["liked"],
2424 );
2525 if (!act.ok) return notFoundPage(ctx);
26 const now = toggleLike(ctx.app.db, viewer.id, act.value.itemId, act.value.target.repoId);
26 // `act` names the wish ("like" unless it says "unlike"), so a repeat press is a no-op.
27 const wish = field(form, "act") === "unlike" ? "unlike" : "like";
28 const now = setLike(ctx.app.db, viewer.id, act.value.itemId, act.value.target.repoId, wish);
2729 return flashOrJson(ctx, backTo(form, "/feed"), { message: now.liked ? "Liked." : "Like taken back." }, { ...now });
2830 }
test/conversation-confirm.test.ts +1 −1
@@ -122,7 +122,7 @@ describe("the one list of what asks first", () => {
122122 const NOT_ASKED = new Set([
123123 "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas",
124124 "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125 "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner",
125 "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "feed_chips", "feed_dismiss", "feed_like", "flag_set", "flag_winner",
126126 "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app",
127127 "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports",
128128 "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates",
test/feed-like.test.ts +184 −28
@@ -1,6 +1,6 @@
11 /**
2 * A like on a feed item (#7751): one press likes, a second takes it back,
3 * one person counts once, and an item the viewer cannot read is a 404.
2 * A like on a feed item (#7751, #10389): a ledger row (who, what, when), idempotent;
3 * unlike is a new row; one person counts once; an item the viewer cannot read is a 404.
44 */
55 import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
66 import { mkdtemp, rm } from "node:fs/promises";
@@ -11,6 +11,7 @@ import { createRepo } from "../src/actions/repos.ts";
1111 import { createToken } from "../src/core/tokens.ts";
1212 import { createUser, type User } from "../src/core/users.ts";
1313 import { readFeed } from "../src/feed/feed.ts";
14 import { ledgerCount, likeLedger, setLike } from "../src/feed/likes.ts";
1415 import { createSession } from "../src/core/sessions.ts";
1516 import { feedCard, feedJs } from "../src/web/studio/feed.ts";
1617 import { serve, type Servers } from "../src/http/server.ts";
@@ -69,55 +70,102 @@ afterAll(async () => {
6970 await rm(dataDir, { recursive: true, force: true });
7071 });
7172
72 const like = (tok: string, item: string) =>
73 const send = (tok: string, item: string, kind: string) =>
7374 fetch(`${servers.url}/api/v1/feed/interactions`, {
7475 method: "POST",
7576 headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" },
76 body: JSON.stringify({ item, kind: "liked" }),
77 body: JSON.stringify({ item, kind }),
7778 });
79 const like = (tok: string, item: string) => send(tok, item, "liked");
80 const unlike = (tok: string, item: string) => send(tok, item, "unliked");
81 const ledgerRows = (item: string) =>
82 app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM feed_signal WHERE item_id = ?1").get(item)!.n;
7883 const stateOf = (who: User, item: string) => {
7984 const found = readFeed(app.db, who).items.find((i) => i.id === item)!;
8085 return { likes: found.likes ?? 0, liked: found.liked ?? false };
8186 };
8287
8388 describe("a like", () => {
84 test("is recorded, then taken back by the second press", async () => {
85 expect(stateOf(bob, openEvent)).toEqual({ likes: 0, liked: false });
89 test("is one ledger row, a repeat is a no-op, unlike is a new row, and the count is the ledger's", async () => {
90 const check = (likes: number, liked: boolean) => {
91 expect(stateOf(bob, openEvent)).toEqual({ likes, liked });
92 expect(ledgerCount(app.db, openEvent)).toBe(likes);
93 };
94 check(0, false);
8695 const first = await like(bobTok, openEvent);
8796 expect(first.status).toBe(201);
8897 expect(await first.json()).toEqual({ likes: 1, liked: true });
89 expect(stateOf(bob, openEvent)).toEqual({ likes: 1, liked: true });
90 const second = await like(bobTok, openEvent);
91 expect(await second.json()).toEqual({ likes: 0, liked: false });
92 expect(stateOf(bob, openEvent)).toEqual({ likes: 0, liked: false });
93 // Taking it back deletes the row: nothing is left of it.
94 expect(app.db.query("SELECT 1 FROM feed_like WHERE item_id = ?1").get(openEvent)).toBeNull();
98 expect(ledgerRows(openEvent)).toBe(1);
99 check(1, true);
100 const again = await like(bobTok, openEvent);
101 expect(await again.json()).toEqual({ likes: 1, liked: true });
102 expect(ledgerRows(openEvent)).toBe(1);
103 check(1, true);
104 const off = await unlike(bobTok, openEvent);
105 expect(await off.json()).toEqual({ likes: 0, liked: false });
106 // History is kept: the like stands, and the unlike is a second row.
107 expect(ledgerRows(openEvent)).toBe(2);
108 check(0, false);
109 expect(await (await unlike(bobTok, openEvent)).json()).toEqual({ likes: 0, liked: false });
110 expect(ledgerRows(openEvent)).toBe(2);
111 await like(bobTok, openEvent);
112 expect(ledgerRows(openEvent)).toBe(3);
113 check(1, true);
114 const rows = likeLedger(app.db, openEvent).rows;
115 expect(rows.map((r) => [r.who, r.act])).toEqual([["bob", "like"], ["bob", "unlike"], ["bob", "like"]].reverse());
116 expect(rows[0]!.at).toMatch(/^\d{4}-/);
117 await unlike(bobTok, openEvent);
118 });
119
120 test("is read back from the API ledger by anyone who may see the item", async () => {
121 await like(aliceTok, openEvent);
122 const res = await fetch(`${servers.url}/api/v1/feed/likes?item=${encodeURIComponent(openEvent)}`, {
123 headers: { authorization: `Bearer ${bobTok}` },
124 });
125 const body = (await res.json()) as { ledger: { who: string; act: string }[] };
126 expect(body.ledger.filter((r) => r.who === "alice").map((r) => r.act)).toEqual(["like"]);
127 const hidden = await fetch(`${servers.url}/api/v1/feed/likes?item=${encodeURIComponent(secretEvent)}`, {
128 headers: { authorization: `Bearer ${bobTok}` },
129 });
130 expect(hidden.status).toBe(404);
131 await unlike(aliceTok, openEvent);
132 });
133
134 test("from an anonymous caller is a 401 naming the sign-in route, and writes nothing", async () => {
135 const before = ledgerRows(openEvent);
136 const res = await fetch(`${servers.url}/api/v1/feed/interactions`, {
137 method: "POST",
138 headers: { "content-type": "application/json" },
139 body: JSON.stringify({ item: openEvent, kind: "liked" }),
140 });
141 expect(res.status).toBe(401);
142 expect(((await res.json()) as { message: string }).message).toContain("/signin");
143 expect(ledgerRows(openEvent)).toBe(before);
144 });
145
146 test("with a read-only token is refused", async () => {
147 const ro = createToken(app.db, bob.id, "ro", "ro").token;
148 expect((await like(ro, openEvent)).status).toBe(403);
95149 });
96150
97151 test("counts each person once, and shows each viewer their own", async () => {
98152 await like(bobTok, openEvent);
99153 await like(aliceTok, openEvent);
154 await like(aliceTok, openEvent);
100155 expect(stateOf(bob, openEvent)).toEqual({ likes: 2, liked: true });
101156 expect(stateOf(alice, openEvent)).toEqual({ likes: 2, liked: true });
102 await like(bobTok, openEvent);
157 await unlike(bobTok, openEvent);
103158 expect(stateOf(bob, openEvent)).toEqual({ likes: 1, liked: false });
104159 expect(stateOf(alice, openEvent)).toEqual({ likes: 1, liked: true });
105 await like(aliceTok, openEvent);
160 expect(ledgerCount(app.db, openEvent)).toBe(1);
161 await unlike(aliceTok, openEvent);
106162 });
107163
108164 test("on an item the viewer cannot read is a 404 and leaves no row", async () => {
109165 expect((await like(bobTok, secretEvent)).status).toBe(404);
110166 expect((await like(bobTok, "event:nothing-here")).status).toBe(404);
111167 expect(app.db.query("SELECT 1 FROM feed_like WHERE item_id = ?1").get(secretEvent)).toBeNull();
112 });
113
114 test("cannot be sent as unliked: that is only the second press", async () => {
115 const res = await fetch(`${servers.url}/api/v1/feed/interactions`, {
116 method: "POST",
117 headers: { authorization: `Bearer ${bobTok}`, "content-type": "application/json" },
118 body: JSON.stringify({ item: openEvent, kind: "unliked" }),
119 });
120 expect(res.status).toBe(422);
168 expect(ledgerRows(secretEvent)).toBe(0);
121169 });
122170 });
123171
@@ -162,16 +210,17 @@ describe("the Like button", () => {
162210 describe("the doors", () => {
163211 beforeEach(() => void app.db.run("DELETE FROM feed_like"));
164212 const cookie = () => `forge_session=${createSession(app.db, bob.id).token}`;
165 const form = (headers: Record<string, string>, item = openEvent) =>
213 const form = (headers: Record<string, string>, item = openEvent, act = "like") =>
166214 fetch(`${servers.url}/feed/like`, {
167215 method: "POST",
168216 redirect: "manual",
169217 headers: { "content-type": "application/x-www-form-urlencoded", ...headers },
170 body: new URLSearchParams({ item, app: "alice/open" }).toString(),
218 body: new URLSearchParams({ item, app: "alice/open", act }).toString(),
171219 });
172220 const likesNow = () => app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM feed_like WHERE item_id = ?1").get(openEvent)!.n;
173221
174222 test("GET /api/v1/feed carries likes and liked for the viewer, and omits them where nobody likes", async () => {
223 await like(aliceTok, openEvent);
175224 await like(aliceTok, openEvent);
176225 const get = async (tok: string) =>
177226 ((await (await fetch(`${servers.url}/api/v1/feed`, { headers: { authorization: `Bearer ${tok}` } })).json()) as {
@@ -179,7 +228,7 @@ describe("the doors", () => {
179228 }).items.find((i) => i.id === openEvent)!;
180229 expect(await get(aliceTok)).toMatchObject({ likes: 1, liked: true });
181230 expect(await get(bobTok)).toMatchObject({ likes: 1, liked: false });
182 await like(aliceTok, openEvent);
231 await unlike(aliceTok, openEvent);
183232 const none = await get(bobTok);
184233 expect(none.likes).toBeUndefined();
185234 expect(none.liked).toBeUndefined();
@@ -201,9 +250,11 @@ describe("the doors", () => {
201250 });
202251
203252 test("asked for JSON, /feed/like answers the new count", async () => {
204 const on = await form({ authorization: `Bearer ${bobTok}`, accept: "application/json" });
253 const on = await form({ authorization: `Bearer ${bobTok}`, accept: "application/json" }, openEvent, "like");
205254 expect(await on.json()).toMatchObject({ likes: 1, liked: true });
206 const off = await form({ authorization: `Bearer ${bobTok}`, accept: "application/json" });
255 const repeat = await form({ authorization: `Bearer ${bobTok}`, accept: "application/json" }, openEvent, "like");
256 expect(await repeat.json()).toMatchObject({ likes: 1, liked: true });
257 const off = await form({ authorization: `Bearer ${bobTok}`, accept: "application/json" }, openEvent, "unlike");
207258 expect(await off.json()).toMatchObject({ likes: 0, liked: false });
208259 });
209260 });
@@ -255,3 +306,108 @@ describe("the live script", () => {
255306 expect(state.read()).toBe("");
256307 });
257308 });
309
310 describe("one ledger from every door", () => {
311 const mcp = async (tok: string, args: unknown) => {
312 const res = await fetch(`${servers.url}/mcp`, {
313 method: "POST",
314 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok}` },
315 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "feed_like", arguments: args } }),
316 });
317 const body: any = await res.json();
318 const text: string = body.result.content[0].text;
319 try { return JSON.parse(text); } catch { return text; }
320 };
321
322 test("the page, the API and the MCP each write a row for their own caller, and the count matches", async () => {
323 app.db.run("DELETE FROM feed_like");
324 app.db.run("DELETE FROM feed_signal");
325 const cookie = `forge_session=${createSession(app.db, alice.id).token}`;
326 const page = await fetch(`${servers.url}/feed/like`, {
327 method: "POST",
328 redirect: "manual",
329 headers: { "content-type": "application/x-www-form-urlencoded", cookie, origin: servers.url },
330 body: new URLSearchParams({ item: openEvent, app: "alice/open", act: "like" }).toString(),
331 });
332 expect(page.status).toBe(303);
333 await like(bobTok, openEvent);
334 const founder = createToken(app.db, (app.db.query("SELECT id FROM user WHERE username = 'founder'").get() as { id: string }).id, "rw", "rw").token;
335 expect(await mcp(founder, { item: openEvent, act: "like" })).toEqual({ likes: 3, liked: true });
336 expect(await mcp(founder, { item: openEvent, act: "like" })).toEqual({ likes: 3, liked: true });
337 expect(likeLedger(app.db, openEvent).rows.map((r) => r.who).sort()).toEqual(["alice", "bob", "founder"]);
338 expect(stateOf(alice, openEvent).likes).toBe(ledgerCount(app.db, openEvent));
339 expect(await mcp(founder, { item: openEvent, act: "unlike" })).toEqual({ likes: 2, liked: false });
340 const history = await mcp(founder, { item: openEvent, act: "history" });
341 expect(history.ledger.map((r: { who: string; act: string }) => `${r.who}:${r.act}`)).toEqual(["alice:like", "bob:like", "founder:like", "founder:unlike"].reverse());
342 expect(stateOf(alice, openEvent).likes).toBe(ledgerCount(app.db, openEvent));
343 });
344
345 test("the MCP refuses an unseen item and a bad act, writing nothing", async () => {
346 const before = ledgerRows(secretEvent);
347 expect(String(await mcp(bobTok, { item: secretEvent, act: "like" }))).toContain("not visible");
348 expect(ledgerRows(secretEvent)).toBe(before);
349 expect(String(await mcp(bobTok, { item: openEvent, act: "nope" }))).toContain("act is one of");
350 });
351
352 test("newest first, a page at a time, with `more` and a `before` cursor, over API and MCP", async () => {
353 const e = openEvent;
354 app.db.run("DELETE FROM feed_signal WHERE item_id = ?1", [e]);
355 app.db.run("DELETE FROM feed_like WHERE item_id = ?1", [e]);
356 const repoId = (app.db.query("SELECT id FROM repo WHERE name = 'open'").get() as { id: string }).id;
357 for (let i = 0; i < 60; i++) setLike(app.db, bob.id, e, repoId, i % 2 === 0 ? "like" : "unlike");
358 const page = likeLedger(app.db, e);
359 expect(page.rows).toHaveLength(50);
360 expect(page.more).toBe(true);
361 expect(page.rows[0]!.seq).toBeGreaterThan(page.rows[49]!.seq);
362 const rest = likeLedger(app.db, e, page.rows[49]!.seq);
363 expect(rest.rows).toHaveLength(10);
364 expect(rest.more).toBe(false);
365 expect(rest.rows[0]!.seq).toBeLessThan(page.rows[49]!.seq);
366 const founder = createToken(app.db, (app.db.query("SELECT id FROM user WHERE username = 'founder'").get() as { id: string }).id, "rw", "rw").token;
367 const mcpRest = await mcp(founder, { item: e, act: "history", before: page.rows[49]!.seq });
368 expect(mcpRest.ledger).toHaveLength(10);
369 expect(mcpRest.more).toBe(false);
370 expect(String(await mcp(founder, { item: e, act: "history", before: "x" }))).toContain("before");
371 });
372
373 test("the API takes `before` and refuses a bad one", async () => {
374 const get = (q: string) => fetch(`${servers.url}/api/v1/feed/likes?item=${encodeURIComponent(openEvent)}${q}`, { headers: { authorization: `Bearer ${bobTok}` } });
375 const all = (await (await get("")).json()) as { ledger: { seq: number }[]; more: boolean };
376 expect(all.ledger).toHaveLength(50);
377 expect(all.more).toBe(true);
378 const older = (await (await get(`&before=${all.ledger[49]!.seq}`)).json()) as { ledger: unknown[]; more: boolean };
379 expect(older.ledger).toHaveLength(10);
380 expect(older.more).toBe(false);
381 expect((await get("&before=abc")).status).toBe(422);
382 });
383 });
384
385 describe("like-flooding", () => {
386 test("a token that floods likes meets the per-token rate limit, and writes no more than it was let", async () => {
387 const dir = await mkdtemp(join(tmpdir(), "forge-like-rate-"));
388 process.env["FORGE_RATE_LIMIT"] = "5";
389 const a = createApp({ dataDir: dir, port: 0 });
390 const s = await serve(a);
391 delete process.env["FORGE_RATE_LIMIT"];
392 try {
393 const u = await createUser(a.db, { origin: "seed", trusted: true, username: "flood", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
394 if (!u.ok) throw new Error(u.error);
395 const tok = createToken(a.db, u.value.id, "rw", "rw").token;
396 const codes: number[] = [];
397 for (let i = 0; i < 12; i++) {
398 const r = await fetch(`${s.url}/api/v1/feed/interactions`, {
399 method: "POST",
400 headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" },
401 body: JSON.stringify({ item: "event:nothing", kind: i % 2 ? "unliked" : "liked" }),
402 });
403 codes.push(r.status);
404 }
405 expect(codes.slice(0, 5).every((c) => c === 404)).toBe(true);
406 expect(codes.slice(5).every((c) => c === 429)).toBe(true);
407 } finally {
408 s.stop();
409 a.close();
410 await rm(dir, { recursive: true, force: true });
411 }
412 });
413 });
test/mcp.test.ts +1
@@ -320,6 +320,7 @@ describe("the forge over MCP", () => {
320320 "feed",
321321 "feed_chips",
322322 "feed_dismiss",
323 "feed_like",
323324 "maintenance",
324325 "make_it_yours",
325326 "origin_apps",
test/palette-roster.test.ts +3 −3
@@ -145,7 +145,7 @@ test("a site-admin person is offered the whole roster", () => {
145145 ["bets", "Bets", "back, stop and finish a repository\'s bets"],
146146 ["origins", "Connected origins", "the hosts your mirrors come from"],
147147 ["domains", "Failure domains", "failure domains and their last drill"],
148 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
148 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes, h who liked"],
149149 ["feature-review", "Menu entries to review", "absorbed apps whose declared name or page changed: accept them"],
150150 ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"],
151151 ["features", "Features", "apps that declare a feature: absorb one, change its settings"],
@@ -218,7 +218,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
218218 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
219219 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
220220 ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"],
221 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
221 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes, h who liked"],
222222 ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"],
223223 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
224224 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
@@ -291,7 +291,7 @@ test("a member person is offered the signed-in roster without the admin entries"
291291 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
292292 ["bets", "Bets", "back, stop and finish a repository\'s bets"],
293293 ["origins", "Connected origins", "the hosts your mirrors come from"],
294 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
294 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes, h who liked"],
295295 ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"],
296296 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
297297 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
test/tui-feed-reply.test.ts +19
@@ -9,6 +9,7 @@ import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { openIssue } from "../src/actions/issues.ts";
1111 import { openPull } from "../src/actions/pulls.ts";
12 import { readInteraction } from "../src/feed/interact.ts";
1213 import { readFeed } from "../src/feed/feed.ts";
1314 import { replyTarget } from "../src/feed/reply.ts";
1415 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
@@ -174,6 +175,24 @@ test("l on a post the person cannot read likes nothing", async () => {
174175 t.stop();
175176 });
176177
178 test("h on a post lists who liked it and when, newest first, and shows nothing for a post the person cannot read", async () => {
179 const t = boot("alice");
180 await t.open();
181 await t.press(...downTo("alice", `/alice/open/pulls/${pullNumber}`));
182 await t.press("h");
183 expect(t.text()).toContain("nobody has liked it yet");
184 await t.press("esc");
185 await t.press("l");
186 await t.press("h");
187 expect(t.text()).toContain("alice like");
188
189 t.stop();
190 // The ledger read is gated by the same check: a post bob cannot read is refused.
191 const post = readFeed(forge.db, getUserByName(forge.db, "alice")!, { limit: 20 }).items.find((i) => i.app === "alice/secret")!;
192 const refused = readInteraction(forge.db, getUserByName(forge.db, "bob")!, { item: post.id, kind: "liked", app: post.app }, ["liked"]);
193 expect(refused.ok).toBe(false);
194 });
195
177196 test("t switches the feed to every post, newest first, and back", async () => {
178197 const t = boot("alice");
179198 await t.open();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs