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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10389-a-like-is-a-signal-in-the-decision-ledge → main 9 commits fork 3 threads · 3 unresolved
20 files
changed +464
−54
docs/FEED.md +8 −2
| @@ -175,6 +175,12 @@ lean client home has no feed script, so no draft. | ||
| 175 | 175 | |
| 176 | 176 | ## Likes |
| 177 | 177 | |
| 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. | |
| 179 | 179 | |
| 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. | |
| 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 @@ | ||
| 15 | 15 | "src/flow/review_rounds.ts", |
| 16 | 16 | "src/flow/shared_red.ts", |
| 17 | 17 | "src/flow/stale_fold.ts", |
| 18 | "src/kernel/why_now.ts", | |
| 18 | 19 | "src/spawn/pod.ts", |
| 19 | 20 | "src/tui/gallery.ts", |
| 20 | 21 | "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"; | ||
| 76 | 76 | export { route as getApiV1FeaturesSlugFeed } from "./getApiV1FeaturesSlugFeed.ts"; |
| 77 | 77 | export { route as getApiV1Feed } from "./getApiV1Feed.ts"; |
| 78 | 78 | export { route as getApiV1FeedChips } from "./getApiV1FeedChips.ts"; |
| 79 | export { route as getApiV1FeedLikes } from "./getApiV1FeedLikes.ts"; | |
| 79 | 80 | export { route as getApiV1Flags } from "./getApiV1Flags.ts"; |
| 80 | 81 | export { route as getApiV1FleetGraph } from "./getApiV1FleetGraph.ts"; |
| 81 | 82 | 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"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "post", |
| 5 | 5 | 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 }", | |
| 7 | 7 | auth: true, |
| 8 | 8 | status: 201, |
| 9 | 9 | 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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import type { Database } from "bun:sqlite"; |
| @@ -28,10 +28,79 @@ export function likesOf( | ||
| 28 | 28 | return out; |
| 29 | 29 | } |
| 30 | 30 | |
| 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 | })(); | |
| 36 | 53 | return likesOf(db, viewerId, [itemId]).get(itemId)!; |
| 37 | 54 | } |
| 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 { | ||
| 13 | 13 | import { parseFrom, TELL_TEXT_MAX } from "../actions/tell_from.ts"; |
| 14 | 14 | import { DIRECTION_NOTE } from "../crew/direct.ts"; |
| 15 | 15 | import { readInteraction } from "../feed/interact.ts"; |
| 16 | import { toggleLike } from "../feed/likes.ts"; | |
| 16 | import { likeLedger, parseLedgerBefore, setLike } from "../feed/likes.ts"; | |
| 17 | 17 | import { |
| 18 | 18 | ITEM_ID, |
| 19 | 19 | INTERACTION_KINDS, |
| @@ -59,8 +59,12 @@ import { | ||
| 59 | 59 | unauthorized, |
| 60 | 60 | } from "./respond.ts"; |
| 61 | 61 | |
| 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"); | |
| 64 | 68 | |
| 65 | 69 | export function feedRoutes(router: Router<Handler>): void { |
| 66 | 70 | router.add("GET", "/api/v1/feed", (ctx) => { |
| @@ -258,6 +262,7 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 258 | 262 | }); |
| 259 | 263 | |
| 260 | 264 | router.add("POST", "/api/v1/feed/interactions", async (ctx) => { |
| 265 | if (!ctx.auth.user) return signInFirst401(); | |
| 261 | 266 | const denied = requireScope(ctx, "rw"); |
| 262 | 267 | if (denied) return denied; |
| 263 | 268 | const viewer = ctx.auth.user!; |
| @@ -267,8 +272,11 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 267 | 272 | if (!act.ok) |
| 268 | 273 | return apiError(act.error.code, act.error.code === "not_found" ? 404 : 422, act.error.message); |
| 269 | 274 | 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 | } | |
| 272 | 280 | const made = recordInteraction(db, { |
| 273 | 281 | viewerId: viewer.id, |
| 274 | 282 | 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 { | ||
| 279 | 287 | return json({ id: made.id }, 201); |
| 280 | 288 | }); |
| 281 | 289 | |
| 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 | ||
| 282 | 310 | // A tell from a card, as JSON, so the outbox can replay it by key. |
| 283 | 311 | router.add("POST", "/api/v1/feed/tells", async (ctx) => { |
| 284 | 312 | 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"; | ||
| 61 | 61 | export { tool as feed } from "./feed.ts"; |
| 62 | 62 | export { tool as feed_chips } from "./feed_chips.ts"; |
| 63 | 63 | export { tool as feed_dismiss } from "./feed_dismiss.ts"; |
| 64 | export { tool as feed_like } from "./feed_like.ts"; | |
| 64 | 65 | export { tool as flag_set } from "./flag_set.ts"; |
| 65 | 66 | export { tool as flag_winner } from "./flag_winner.ts"; |
| 66 | 67 | export { tool as flags } from "./flags.ts"; |
src/tui/screens/feed.ts +40 −3
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | * `addCommentThrough` the issue and pull screens call, offered on the same |
| 7 | 7 | * posts to the same people as the page's Reply (`feed/reply.ts`). `l` likes |
| 8 | 8 | * 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 | |
| 10 | 11 | * posts at a time; `t` switches to every post, newest first (`readTimeline`); `n` reads the next page from the cursor the last returned. |
| 11 | 12 | */ |
| 12 | 13 | |
| @@ -16,11 +17,13 @@ import { parseCursor, readFeed } from "../../feed/feed.ts"; | ||
| 16 | 17 | import { parseBefore, readTimeline, TIMELINE_START } from "../../feed/timeline.ts"; |
| 17 | 18 | import type { FeedItem } from "../../feed/rank.ts"; |
| 18 | 19 | import { readInteraction } from "../../feed/interact.ts"; |
| 19 | import { toggleLike } from "../../feed/likes.ts"; | |
| 20 | import { likeLedger, toggleLike } from "../../feed/likes.ts"; | |
| 20 | 21 | import { replyTarget } from "../../feed/reply.ts"; |
| 21 | 22 | import type { Ctx, Screen } from "../app/app.ts"; |
| 22 | 23 | import { binding } from "../app/keys.ts"; |
| 23 | 24 | import type { Session } from "../session.ts"; |
| 25 | import { style } from "../term/style.ts"; | |
| 26 | import { truncate } from "../term/width.ts"; | |
| 24 | 27 | import { fill, len, split } from "../ui/layout.ts"; |
| 25 | 28 | import { stepLine } from "../../flow/path.ts"; |
| 26 | 29 | import { emptyState } from "../ui/widgets.ts"; |
| @@ -86,6 +89,24 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 86 | 89 | ctx.invalidate(); |
| 87 | 90 | }; |
| 88 | 91 | |
| 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 | ||
| 89 | 110 | const submit = (): void => { |
| 90 | 111 | const at = selected(); |
| 91 | 112 | if (at === undefined) return; |
| @@ -110,6 +131,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 110 | 131 | binding(["j", "k"], "move"), |
| 111 | 132 | ...(selected() === undefined ? [] : [binding(["r"], "reply")]), |
| 112 | 133 | ...(items.length === 0 ? [] : [binding(["l"], "like")]), |
| 134 | ...(items.length === 0 ? [] : [binding(["h"], "who liked")]), | |
| 113 | 135 | ...(next === null ? [] : [binding(["n"], "next page")]), |
| 114 | 136 | binding(["t"], timeline ? "ranked" : "newest first"), |
| 115 | 137 | binding(["esc", "q"], "back"), |
| @@ -121,6 +143,14 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 121 | 143 | emptyState(s, area, c, { title: "Nothing in the feed yet", body: "Posts about the apps you can see land here." }); |
| 122 | 144 | return; |
| 123 | 145 | } |
| 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 | } | |
| 124 | 154 | const [list, box] = split(area, "v", [fill(1), len(composer.height())]); |
| 125 | 155 | const rows: ListRow[] = items.map((item) => ({ |
| 126 | 156 | title: oneLine(`${item.actor ?? item.app} ${item.what}`), |
| @@ -139,11 +169,18 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 139 | 169 | else if (out === "cancel") composer.close(); |
| 140 | 170 | return true; |
| 141 | 171 | } |
| 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 | } | |
| 142 | 178 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 143 | 179 | else if (e.key === "r") { |
| 144 | 180 | const at = selected(); |
| 145 | 181 | if (at !== undefined) composer.start(`Reply on ${at.thread.owner}/${at.thread.name} #${at.thread.number}`); |
| 146 | 182 | } else if (e.key === "l") like(); |
| 183 | else if (e.key === "h") showHistory(); | |
| 147 | 184 | else if (e.key === "t") { |
| 148 | 185 | timeline = !timeline; |
| 149 | 186 | items.length = 0; |
| @@ -162,7 +199,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 162 | 199 | export const entry: PaletteEntry = { |
| 163 | 200 | id: "feed", |
| 164 | 201 | 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", | |
| 166 | 203 | offered: (session) => session.user !== undefined, |
| 167 | 204 | run: (session, ctx) => ctx.push(feedScreen(session, ctx)), |
| 168 | 205 | }; |
src/web/studio/feed.ts +4 −2
| @@ -118,6 +118,7 @@ function likeBar(item: FeedItem): Html { | ||
| 118 | 118 | <input type="hidden" name="item" value="${item.id}" /> |
| 119 | 119 | <input type="hidden" name="app" value="${item.app}" /> |
| 120 | 120 | <input type="hidden" name="back" value="/feed" /> |
| 121 | <input type="hidden" name="act" value="${liked ? "unlike" : "like"}" /> | |
| 121 | 122 | <button type="submit" class="btn" data-variant="ghost" data-size="sm" aria-pressed="${liked ? "true" : "false"}">Like <span data-likes>${likes}</span></button> |
| 122 | 123 | </form>`; |
| 123 | 124 | } |
| @@ -613,10 +614,11 @@ ${dayKeepJs} | ||
| 613 | 614 | e.preventDefault(); |
| 614 | 615 | var was = btn.getAttribute('aria-pressed') === 'true'; |
| 615 | 616 | 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(); | |
| 617 | 619 | show(!was, Math.max(0, n + (was ? -1 : 1))); |
| 618 | 620 | 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 }) | |
| 620 | 622 | .then(function (r) { if (!r.ok) throw r; return r.json(); }) |
| 621 | 623 | .then(function (a) { show(a.liked, a.likes); }) |
| 622 | 624 | .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 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { readInteraction } from "../../feed/interact.ts"; |
| 8 | import { toggleLike } from "../../feed/likes.ts"; | |
| 8 | import { setLike } from "../../feed/likes.ts"; | |
| 9 | 9 | import type { Ctx } from "../../http/context.ts"; |
| 10 | 10 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 11 | 11 | import { backTo, field, readForm } from "../form.ts"; |
| @@ -23,6 +23,8 @@ export async function likeSubmit(ctx: Ctx): Promise<Response> { | ||
| 23 | 23 | ["liked"], |
| 24 | 24 | ); |
| 25 | 25 | 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); | |
| 27 | 29 | return flashOrJson(ctx, backTo(form, "/feed"), { message: now.liked ? "Liked." : "Like taken back." }, { ...now }); |
| 28 | 30 | } |
test/conversation-confirm.test.ts +1 −1
| @@ -122,7 +122,7 @@ describe("the one list of what asks first", () => { | ||
| 122 | 122 | const NOT_ASKED = new Set([ |
| 123 | 123 | "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas", |
| 124 | 124 | "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", | |
| 126 | 126 | "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app", |
| 127 | 127 | "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", |
| 128 | 128 | "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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 4 | 4 | */ |
| 5 | 5 | import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; |
| 6 | 6 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -11,6 +11,7 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 11 | 11 | import { createToken } from "../src/core/tokens.ts"; |
| 12 | 12 | import { createUser, type User } from "../src/core/users.ts"; |
| 13 | 13 | import { readFeed } from "../src/feed/feed.ts"; |
| 14 | import { ledgerCount, likeLedger, setLike } from "../src/feed/likes.ts"; | |
| 14 | 15 | import { createSession } from "../src/core/sessions.ts"; |
| 15 | 16 | import { feedCard, feedJs } from "../src/web/studio/feed.ts"; |
| 16 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -69,55 +70,102 @@ afterAll(async () => { | ||
| 69 | 70 | await rm(dataDir, { recursive: true, force: true }); |
| 70 | 71 | }); |
| 71 | 72 | |
| 72 | const like = (tok: string, item: string) => | |
| 73 | const send = (tok: string, item: string, kind: string) => | |
| 73 | 74 | fetch(`${servers.url}/api/v1/feed/interactions`, { |
| 74 | 75 | method: "POST", |
| 75 | 76 | headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, |
| 76 | body: JSON.stringify({ item, kind: "liked" }), | |
| 77 | body: JSON.stringify({ item, kind }), | |
| 77 | 78 | }); |
| 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; | |
| 78 | 83 | const stateOf = (who: User, item: string) => { |
| 79 | 84 | const found = readFeed(app.db, who).items.find((i) => i.id === item)!; |
| 80 | 85 | return { likes: found.likes ?? 0, liked: found.liked ?? false }; |
| 81 | 86 | }; |
| 82 | 87 | |
| 83 | 88 | 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); | |
| 86 | 95 | const first = await like(bobTok, openEvent); |
| 87 | 96 | expect(first.status).toBe(201); |
| 88 | 97 | 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); | |
| 95 | 149 | }); |
| 96 | 150 | |
| 97 | 151 | test("counts each person once, and shows each viewer their own", async () => { |
| 98 | 152 | await like(bobTok, openEvent); |
| 99 | 153 | await like(aliceTok, openEvent); |
| 154 | await like(aliceTok, openEvent); | |
| 100 | 155 | expect(stateOf(bob, openEvent)).toEqual({ likes: 2, liked: true }); |
| 101 | 156 | expect(stateOf(alice, openEvent)).toEqual({ likes: 2, liked: true }); |
| 102 | await like(bobTok, openEvent); | |
| 157 | await unlike(bobTok, openEvent); | |
| 103 | 158 | expect(stateOf(bob, openEvent)).toEqual({ likes: 1, liked: false }); |
| 104 | 159 | 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); | |
| 106 | 162 | }); |
| 107 | 163 | |
| 108 | 164 | test("on an item the viewer cannot read is a 404 and leaves no row", async () => { |
| 109 | 165 | expect((await like(bobTok, secretEvent)).status).toBe(404); |
| 110 | 166 | expect((await like(bobTok, "event:nothing-here")).status).toBe(404); |
| 111 | 167 | 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); | |
| 121 | 169 | }); |
| 122 | 170 | }); |
| 123 | 171 | |
| @@ -162,16 +210,17 @@ describe("the Like button", () => { | ||
| 162 | 210 | describe("the doors", () => { |
| 163 | 211 | beforeEach(() => void app.db.run("DELETE FROM feed_like")); |
| 164 | 212 | 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") => | |
| 166 | 214 | fetch(`${servers.url}/feed/like`, { |
| 167 | 215 | method: "POST", |
| 168 | 216 | redirect: "manual", |
| 169 | 217 | 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(), | |
| 171 | 219 | }); |
| 172 | 220 | const likesNow = () => app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM feed_like WHERE item_id = ?1").get(openEvent)!.n; |
| 173 | 221 | |
| 174 | 222 | test("GET /api/v1/feed carries likes and liked for the viewer, and omits them where nobody likes", async () => { |
| 223 | await like(aliceTok, openEvent); | |
| 175 | 224 | await like(aliceTok, openEvent); |
| 176 | 225 | const get = async (tok: string) => |
| 177 | 226 | ((await (await fetch(`${servers.url}/api/v1/feed`, { headers: { authorization: `Bearer ${tok}` } })).json()) as { |
| @@ -179,7 +228,7 @@ describe("the doors", () => { | ||
| 179 | 228 | }).items.find((i) => i.id === openEvent)!; |
| 180 | 229 | expect(await get(aliceTok)).toMatchObject({ likes: 1, liked: true }); |
| 181 | 230 | expect(await get(bobTok)).toMatchObject({ likes: 1, liked: false }); |
| 182 | await like(aliceTok, openEvent); | |
| 231 | await unlike(aliceTok, openEvent); | |
| 183 | 232 | const none = await get(bobTok); |
| 184 | 233 | expect(none.likes).toBeUndefined(); |
| 185 | 234 | expect(none.liked).toBeUndefined(); |
| @@ -201,9 +250,11 @@ describe("the doors", () => { | ||
| 201 | 250 | }); |
| 202 | 251 | |
| 203 | 252 | 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"); | |
| 205 | 254 | 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"); | |
| 207 | 258 | expect(await off.json()).toMatchObject({ likes: 0, liked: false }); |
| 208 | 259 | }); |
| 209 | 260 | }); |
| @@ -255,3 +306,108 @@ describe("the live script", () => { | ||
| 255 | 306 | expect(state.read()).toBe(""); |
| 256 | 307 | }); |
| 257 | 308 | }); |
| 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", () => { | ||
| 320 | 320 | "feed", |
| 321 | 321 | "feed_chips", |
| 322 | 322 | "feed_dismiss", |
| 323 | "feed_like", | |
| 323 | 324 | "maintenance", |
| 324 | 325 | "make_it_yours", |
| 325 | 326 | "origin_apps", |
test/palette-roster.test.ts +3 −3
| @@ -145,7 +145,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 145 | 145 | ["bets", "Bets", "back, stop and finish a repository\'s bets"], |
| 146 | 146 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 147 | 147 | ["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"], | |
| 149 | 149 | ["feature-review", "Menu entries to review", "absorbed apps whose declared name or page changed: accept them"], |
| 150 | 150 | ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"], |
| 151 | 151 | ["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", | ||
| 218 | 218 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 219 | 219 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 220 | 220 | ["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"], | |
| 222 | 222 | ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"], |
| 223 | 223 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 224 | 224 | ["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" | ||
| 291 | 291 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 292 | 292 | ["bets", "Bets", "back, stop and finish a repository\'s bets"], |
| 293 | 293 | ["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"], | |
| 295 | 295 | ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"], |
| 296 | 296 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 297 | 297 | ["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"; | ||
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { openIssue } from "../src/actions/issues.ts"; |
| 11 | 11 | import { openPull } from "../src/actions/pulls.ts"; |
| 12 | import { readInteraction } from "../src/feed/interact.ts"; | |
| 12 | 13 | import { readFeed } from "../src/feed/feed.ts"; |
| 13 | 14 | import { replyTarget } from "../src/feed/reply.ts"; |
| 14 | 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| @@ -174,6 +175,24 @@ test("l on a post the person cannot read likes nothing", async () => { | ||
| 174 | 175 | t.stop(); |
| 175 | 176 | }); |
| 176 | 177 | |
| 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 | ||
| 177 | 196 | test("t switches the feed to every post, newest first, and back", async () => { |
| 178 | 197 | const t = boot("alice"); |
| 179 | 198 | await t.open(); |
Stale: the terminal and page no longer both run
toggleLike; the page runssetLike. And the how-to should live in docs/features/ (finding 2).