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

A person likes a post in the feed: one press records a like, a second takes it back, and the post shows how many #8244

Merged opened this

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

Merged

mason/forge:mason/7751-a-person-likes-a-post-in-the-feed-one-pr → main 5 commits fork

  • Merged. mason/7751-a-person-likes-a-post-in-the-feed-one-pr was merged into main as e598931.

Review

Sign in to review this change.
18 files changed +470 −16
docs/FEED.md +6
@@ -172,3 +172,9 @@ keeps them; signing out clears every draft. It lives in the feed's existing
172172 script (no new script, no dependency). Not covered: the "the forge" thought
173173 target posts as a plain form, so its draft is dropped on submit, and the
174174 lean client home has no feed script, so no draft.
175
176 ## Likes
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.
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.
docs/design/areas/feed.md +1
@@ -4,3 +4,4 @@
44 - `.feed` — the home's timeline list: a one-column grid with no gap that holds the feed's cards, each focusable with a visible outline and removed from layout while `hidden`. Drawn by `drawRegion` in `src/web/studio/feed.ts` (the `#live-feed` section), styled in `src/web/studio/feed_style.ts`.
55 - Day divider — no new class: a §5 `.muted.small` line with `data-day-divider="<label>"`, drawn by `drawRegion` in `src/web/studio/feed.ts` before the first post of each UTC day (labels from `day_divider.ts`). It marks a change of day between neighbours in the order the feed already has; it never reorders. It is `role="separator"`, and hiding a card (dismiss, mute) moves it to the next card showing that day or hides it (`keepDay`). The "Older posts" link and `data-live-src` carry `day=` so a later page does not repeat the divider.
66 - Folded burst lines — no new class: under a folded post (`planned N issues in owner/name`) `feedCard` draws the §5 `ul.list` of `row()`s, one linked row per title and a last unlinked row `and N more`.
7 - `.like` — the Like form under a post, drawn by `likeBar` in `src/web/studio/feed.ts`: an inline form around one §5 ghost `.btn` (`aria-pressed` is the only state signal; the label is always "Like"). Its count `[data-likes]` is `inline-block` at `min-width: 3ch`, right-aligned with tabular numerals, so going from 9 to 10 or pressing moves nothing. Styled in `src/web/studio/feed_style.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; 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` is a toggle, a second like takes it back, and answers 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/20261102160000-feed-likes.pin added +1
@@ -0,0 +1,1 @@
1 a731ad77b9965cd2d66b6cdd71b6d95eb9a38207d3086aabd025d7b1ad268225
src/core/migrations/20261102160000-feed-likes.ts added +14
@@ -0,0 +1,14 @@
1 // A like is one row per person and post (#7751): pressing again deletes it.
2 // A table of its own, so `interaction`'s CHECK is left alone and the
3 // migration only adds. `feed_like_item` counts a post's likes without
4 // reading anyone's history.
5 export default `
6 CREATE TABLE feed_like (
7 viewer_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
8 item_id TEXT NOT NULL,
9 repo_id TEXT REFERENCES repo(id) ON DELETE CASCADE,
10 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
11 PRIMARY KEY (viewer_id, item_id)
12 );
13 CREATE INDEX feed_like_item ON feed_like(item_id);
14 `;
src/feed/feed.ts +9 −1
@@ -19,6 +19,7 @@ import { writeStamp } from "../core/write_clock.ts";
1919 import { Stamped } from "../kernel/stamped.ts";
2020 import { foldBursts } from "./fold.ts";
2121 import { rank, type FeedItem } from "./rank.ts";
22 import { likesOf } from "./likes.ts";
2223 import { stepsOf } from "./step.ts";
2324 import { settledOf } from "./settled.ts";
2425
@@ -202,10 +203,17 @@ export function readFeedPage(
202203 const settled = settledOf(db, page);
203204 options.phase?.("settled", performance.now() - started);
204205 const steps = stepsOf(db, page);
206 const likes = likesOf(db, viewer.id, page.map((i) => i.id));
205207 const items = page.map((i) => {
206208 const state = settled.get(i.id);
207209 const step = steps.get(i.id);
208 return { ...i, ...(state === undefined ? {} : { settled: state }), ...(step === undefined ? {} : { step }) };
210 const liked = likes.get(i.id)!;
211 return {
212 ...i,
213 ...(state === undefined ? {} : { settled: state }),
214 ...(step === undefined ? {} : { step }),
215 ...(liked.likes === 0 ? {} : liked),
216 };
209217 });
210218 const last = items[items.length - 1];
211219 return {
src/feed/interact.ts +5 −5
@@ -47,9 +47,9 @@ export interface InteractionRefusal {
4747 readonly message: string;
4848 }
4949
50 export interface InteractionAct {
50 export interface InteractionAct<K extends string = InteractionKind> {
5151 readonly itemId: string;
52 readonly kind: InteractionKind;
52 readonly kind: K;
5353 readonly target: InteractionTarget;
5454 }
5555
@@ -63,12 +63,12 @@ const text = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
6363 * a feed item id, the kind is one of `kinds` (the door's own), and the
6464 * viewer may act on what the item is about.
6565 */
66 export function readInteraction(
66 export function readInteraction<K extends string = InteractionKind>(
6767 db: Database,
6868 viewer: User,
6969 input: { readonly item?: unknown; readonly kind?: unknown; readonly app?: unknown },
70 kinds: readonly InteractionKind[],
71 ): Result<InteractionAct, InteractionRefusal> {
70 kinds: readonly K[],
71 ): Result<InteractionAct<K>, InteractionRefusal> {
7272 const itemId = text(input.item);
7373 if (!ITEM_ID.test(itemId))
7474 return err({ code: "invalid_item", message: "item is a feed item id" });
src/feed/likes.ts added +37
@@ -0,0 +1,37 @@
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.
4 */
5
6 import type { Database } from "bun:sqlite";
7
8 export interface Likes {
9 readonly likes: number;
10 readonly liked: boolean;
11 }
12
13 /** Each item's like count and whether `viewerId` is among them; every asked item is answered. */
14 export function likesOf(
15 db: Database,
16 viewerId: string,
17 itemIds: readonly string[],
18 ): Map<string, Likes> {
19 const out = new Map<string, Likes>(itemIds.map((i) => [i, { likes: 0, liked: false }]));
20 if (itemIds.length === 0) return out;
21 const rows = db
22 .query<{ item_id: string; n: number; mine: number }, [string, string]>(
23 `SELECT item_id, COUNT(*) AS n, SUM(viewer_id = ?2) AS mine FROM feed_like
24 WHERE item_id IN (SELECT value FROM json_each(?1)) GROUP BY item_id`,
25 )
26 .all(JSON.stringify(itemIds), viewerId);
27 for (const r of rows) out.set(r.item_id, { likes: r.n, liked: r.mine > 0 });
28 return out;
29 }
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]);
36 return likesOf(db, viewerId, [itemId]).get(itemId)!;
37 }
src/feed/rank.ts +3
@@ -33,6 +33,9 @@ export interface FeedItem {
3333 readonly look?: FeedLook;
3434 /** For a post about a pull or an issue that is over: how it ended. Absent while open. */
3535 readonly settled?: Settled;
36 /** How many people like it, and whether this viewer does (`feed/likes.ts`); absent when nobody does. */
37 readonly likes?: number;
38 readonly liked?: boolean;
3639 /** For a post about a pull: which of check, review, land, live it is at, and why. */
3740 readonly step?: PathStep;
3841 }
src/http/feed.ts +7 −1
@@ -13,6 +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";
1617 import {
1718 ITEM_ID,
1819 INTERACTION_KINDS,
@@ -55,6 +56,9 @@ import {
5556 unauthorized,
5657 } from "./respond.ts";
5758
59 /** What a card may report: the interaction kinds, and a like (a toggle, not a row of its own). */
60 const DOOR_KINDS = [...INTERACTION_KINDS, "liked" as const];
61
5862 export function feedRoutes(router: Router<Handler>): void {
5963 router.add("GET", "/api/v1/feed", (ctx) => {
6064 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -232,10 +236,12 @@ export function feedRoutes(router: Router<Handler>): void {
232236 const viewer = ctx.auth.user!;
233237 const db = ctx.app.db;
234238 const input = await body(ctx.request);
235 const act = readInteraction(db, viewer, input, INTERACTION_KINDS);
239 const act = readInteraction(db, viewer, input, DOOR_KINDS);
236240 if (!act.ok)
237241 return apiError(act.error.code, act.error.code === "not_found" ? 404 : 422, act.error.message);
238242 const { itemId, kind, target } = act.value;
243 // A like is a toggle: the second press takes it back (`feed/likes.ts`).
244 if (kind === "liked") return json(toggleLike(db, viewer.id, itemId, target.repoId), 201);
239245 const made = recordInteraction(db, {
240246 viewerId: viewer.id,
241247 itemId,
src/http/server.ts +3
@@ -295,6 +295,7 @@ import {
295295 import { keeperAnswer } from "../web/studio/keeper_row.ts";
296296 import { homeArchive, homeArchived, homeArchiveUndo } from "../web/studio/home_archive.ts";
297297 import { liveFeedFragment } from "../web/studio/feed.ts";
298 import { likeSubmit } from "../web/studio/like.ts";
298299 import { liveChipsFragment, liveMovingFragment } from "../web/studio/living.ts";
299300 import { HOME_REGIONS } from "../web/studio/home_last.ts";
300301 import { serviceWorker } from "../web/sw.ts";
@@ -1231,6 +1232,8 @@ export function buildRouter(): Router<Handler> {
12311232 router.add("POST", "/:owner/:name/tell", tellSubmit);
12321233 // The home's composer without a script: a tell to the app it names (#599).
12331234 router.add("POST", "/tell", composerSubmit);
1235 // A like from a card without a script (#7751): the feed's `liked`, then back to the feed.
1236 router.add("POST", "/feed/like", likeSubmit);
12341237 router.add("POST", "/:owner/:name/pulls/:number/checks", pullChecksSubmit);
12351238 router.add("POST", "/:owner/:name/pulls/:number/ship", shipSubmit);
12361239 router.add("POST", "/:owner/:name/pulls/:number/say", saySubmit);
src/tui/screens/feed.ts +29 −4
@@ -4,7 +4,9 @@
44 *
55 * A reply is a comment on the issue or pull the post links to: the same
66 * `addCommentThrough` the issue and pull screens call, offered on the same
7 * posts to the same people as the page's Reply (`feed/reply.ts`). Twenty
7 * posts to the same people as the page's Reply (`feed/reply.ts`). `l` likes
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
810 * posts at a time; `n` reads the next page from the cursor the last returned.
911 */
1012
@@ -12,6 +14,8 @@ import { addCommentThrough } from "../../actions/issues.ts";
1214 import { getIssue } from "../../core/issues.ts";
1315 import { parseCursor, readFeed } from "../../feed/feed.ts";
1416 import type { FeedItem } from "../../feed/rank.ts";
17 import { readInteraction } from "../../feed/interact.ts";
18 import { toggleLike } from "../../feed/likes.ts";
1519 import { replyTarget } from "../../feed/reply.ts";
1620 import type { Ctx, Screen } from "../app/app.ts";
1721 import { binding } from "../app/keys.ts";
@@ -27,6 +31,10 @@ const PAGE = 20;
2731
2832 const oneLine = (text: string): string => text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ");
2933
34 /** `♥ 3` for a post the person likes, `♡ 3` for one others do; nothing when nobody does. */
35 const likeMark = (item: FeedItem): string =>
36 item.liked === true ? `♥ ${item.likes ?? 1}` : (item.likes ?? 0) > 0 ? `♡ ${item.likes}` : "";
37
3038 export function feedScreen(f: Session, ctx: Ctx): Screen {
3139 const state = new ListState();
3240 const composer = new Composer();
@@ -49,6 +57,21 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
4957 return item === undefined ? undefined : replyTarget(f.db, f.user, item);
5058 };
5159
60 /** `l`: like the post under the cursor, or take the like back; the row then shows the count the forge answers. */
61 const like = (): void => {
62 const item = items[state.index];
63 if (item === undefined || f.user === undefined) return;
64 const act = readInteraction(f.db, f.user, { item: item.id, kind: "liked", app: item.app }, ["liked"]);
65 if (!act.ok) {
66 ctx.toast(act.error.message, "danger");
67 return;
68 }
69 const now = toggleLike(f.db, f.user.id, act.value.itemId, act.value.target.repoId);
70 items[state.index] = { ...item, likes: now.likes, liked: now.liked };
71 ctx.toast(now.liked ? "liked" : "like taken back", "success");
72 ctx.invalidate();
73 };
74
5275 const submit = (): void => {
5376 const at = selected();
5477 if (at === undefined) return;
@@ -72,6 +95,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
7295 return [
7396 binding(["j", "k"], "move"),
7497 ...(selected() === undefined ? [] : [binding(["r"], "reply")]),
98 ...(items.length === 0 ? [] : [binding(["l"], "like")]),
7599 ...(next === null ? [] : [binding(["n"], "next page")]),
76100 binding(["esc", "q"], "back"),
77101 ];
@@ -86,7 +110,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
86110 const rows: ListRow[] = items.map((item) => ({
87111 title: oneLine(`${item.actor ?? item.app} ${item.what}`),
88112 secondary: item.app,
89 meta: ago(item.at),
113 meta: [likeMark(item), ago(item.at)].filter((m) => m !== "").join(" "),
90114 ...(item.step === undefined ? {} : { note: stepLine(item.step) }),
91115 }));
92116 drawList(s, list!, c, state, rows);
@@ -104,7 +128,8 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
104128 else if (e.key === "r") {
105129 const at = selected();
106130 if (at !== undefined) composer.start(`Reply on ${at.thread.owner}/${at.thread.name} #${at.thread.number}`);
107 } else if (e.key === "n") {
131 } else if (e.key === "l") like();
132 else if (e.key === "n") {
108133 if (next !== null) read(next);
109134 } else state.key(e, items.length);
110135 return true;
@@ -116,7 +141,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
116141 export const entry: PaletteEntry = {
117142 id: "feed",
118143 title: "Feed",
119 hint: "what happened, ranked; r replies on a post about an issue or pull",
144 hint: "what happened, ranked; r replies on a post about an issue or pull, l likes",
120145 offered: (session) => session.user !== undefined,
121146 run: (session, ctx) => ctx.push(feedScreen(session, ctx)),
122147 };
src/web/studio/feed.ts +38 −1
@@ -102,6 +102,24 @@ function settledBadge(settled: FeedItem["settled"]): Html | null {
102102 return stateBadge(settled === "merged" ? "merged" : "closed", settled === "parked");
103103 }
104104
105 /**
106 * The count of likes and, for a signed-in viewer, the button that toggles
107 * theirs. A form that posts back to the feed, so it works without a script;
108 * the script updates the same two nodes in place. The label never changes
109 * (the pressed state is `aria-pressed`) and the count is drawn at a fixed
110 * width, so a press moves nothing.
111 */
112 function likeBar(item: FeedItem): Html {
113 const likes = item.likes ?? 0;
114 const liked = item.liked ?? false;
115 return html`<form method="post" action="/feed/like" class="like" data-like>
116 <input type="hidden" name="item" value="${item.id}" />
117 <input type="hidden" name="app" value="${item.app}" />
118 <input type="hidden" name="back" value="/feed" />
119 <button type="submit" class="btn" data-variant="ghost" data-size="sm" aria-pressed="${liked ? "true" : "false"}">Like <span data-likes>${likes}</span></button>
120 </form>`;
121 }
122
105123 /**
106124 * One post: who, where and when; what it says, as a link into the thing;
107125 * why, when that is news; and one quiet control for the rest — say
@@ -136,7 +154,8 @@ export function feedCard(
136154 ><img src="${picture.src}" width="${picture.width}" height="${picture.height}" alt="" loading="lazy" decoding="async"
137155 /></a>`}
138156 ${item.step === undefined ? null : html`<p class="break">${stepLink(item.step)}</p>`}
139 ${saysMore(item) ? html`<p class="small muted break">${item.reason}</p>` : null}`;
157 ${saysMore(item) ? html`<p class="small muted break">${item.reason}</p>` : null}
158 ${likeBar(item)}`;
140159 const foot = disclosure(
141160 `${reply ? "Reply or " : ""}${target ? "Steer" : "Tell"} · more`,
142161 html`${reply
@@ -564,11 +583,29 @@ ${dayKeepJs}
564583 if (t.hasAttribute('data-feed-open')) { send(card, 'opened'); return; }
565584 act(card, t.getAttribute('data-feed'));
566585 });
586 // A like flips at once and then takes the count the forge answers; a
587 // refusal puts it back. Without fetch the form posts back to the feed.
588 function like(form, e) {
589 var btn = form.querySelector('button');
590 var count = form.querySelector('[data-likes]');
591 if (!btn || !count) return;
592 e.preventDefault();
593 var was = btn.getAttribute('aria-pressed') === 'true';
594 var n = Number(count.textContent) || 0;
595 var show = function (on, total) { btn.setAttribute('aria-pressed', on ? 'true' : 'false'); count.textContent = String(total); };
596 show(!was, Math.max(0, n + (was ? -1 : 1)));
597 fetch(form.getAttribute('action'), { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
598 body: new URLSearchParams(new FormData(form)).toString() })
599 .then(function (r) { if (!r.ok) throw r; return r.json(); })
600 .then(function (a) { show(a.liked, a.likes); })
601 .catch(function () { show(was, n); problem = 'The like was not saved. Check your connection and press Like again.'; paint(); });
602 }
567603 // A tell is kept and sent the same way; a steer needs the run, so it
568604 // stays an ordinary post and waits for the network.
569605 feed.addEventListener('submit', function (e) {
570606 var form = e.target;
571607 var card = form.closest && form.closest('[data-item]');
608 if (card && form.hasAttribute && form.hasAttribute('data-like')) { like(form, e); return; }
572609 var box = form.elements && form.elements.text;
573610 if (!card || !box || !/\\/tell$/.test(form.getAttribute('action') || '')) return;
574611 var text = box.value.trim();
src/web/studio/feed_style.ts +3
@@ -5,6 +5,9 @@ export const feedCss = /* css */ `
55 .feed { display: grid; gap: 0; }
66 .feed > .card:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
77 .feed > .card[hidden] { display: none; }
8 /* The Like form sits in the card's flow; its count has a fixed width so a press moves nothing. */
9 .feed .like { display: inline-block; margin: 0; }
10 .feed .like [data-likes] { display: inline-block; min-width: 3ch; text-align: end; font-variant-numeric: tabular-nums; }
811 .feed .card-foot > details { flex: 1 1 100%; }
912 /* Your apps: a narrow column on a desk, a tab away on a phone. */
1013 /* An admin's tick boxes are always shown (no Select toggle); 24 px to tap. */
src/web/studio/like.ts added +28
@@ -0,0 +1,28 @@
1 /**
2 * POST /feed/like: the Like button without a script. The same act as the
3 * feed API's `liked` (`feed/likes.ts`), refused the same ways, then back to
4 * the feed; with a script asking for JSON it answers the new count instead.
5 */
6
7 import { readInteraction } from "../../feed/interact.ts";
8 import { toggleLike } from "../../feed/likes.ts";
9 import type { Ctx } from "../../http/context.ts";
10 import { notFoundPage, signInFirst } from "../chrome.ts";
11 import { backTo, field, readForm } from "../form.ts";
12 import { flashOrJson } from "../layout.ts";
13
14 export async function likeSubmit(ctx: Ctx): Promise<Response> {
15 const viewer = ctx.auth.user;
16 if (!viewer) return signInFirst(ctx);
17 const form = await readForm(ctx, "rw");
18 if (form instanceof Response) return form;
19 const act = readInteraction(
20 ctx.app.db,
21 viewer,
22 { item: field(form, "item"), kind: "liked", app: field(form, "app") },
23 ["liked"],
24 );
25 if (!act.ok) return notFoundPage(ctx);
26 const now = toggleLike(ctx.app.db, viewer.id, act.value.itemId, act.value.target.repoId);
27 return flashOrJson(ctx, backTo(form, "/feed"), { message: now.liked ? "Liked." : "Like taken back." }, { ...now });
28 }
test/feed-like.test.ts added +257
@@ -0,0 +1,257 @@
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.
4 */
5 import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { readFeed } from "../src/feed/feed.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { feedCard, feedJs } from "../src/web/studio/feed.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { promote } from "./helpers/members.ts";
18
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let alice: User;
23 let bob: User;
24 let aliceTok: string;
25 let bobTok: string;
26 let openEvent: string;
27 let secretEvent: string;
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-feed-like-"));
31 app = createApp({ dataDir, port: 0 });
32 servers = await serve(app);
33 const mk = async (username: string, first = false) => {
34 const u = await createUser(app.db, {
35 origin: "seed",
36 trusted: true,
37 username,
38 email: `${username}@forge.local`,
39 password: "a-good-passphrase",
40 adminIfFirst: first,
41 });
42 if (!u.ok) throw new Error(u.error);
43 promote(app.db, u.value);
44 return u.value;
45 };
46 await mk("founder", true);
47 alice = await mk("alice");
48 bob = await mk("bob");
49 aliceTok = createToken(app.db, alice.id, "rw", "rw").token;
50 bobTok = createToken(app.db, bob.id, "rw", "rw").token;
51 const o = await createRepo(app, alice, { name: "open", visibility: "public" });
52 const s = await createRepo(app, alice, { name: "secret", visibility: "private" });
53 if (!o.ok || !s.ok) throw new Error("repos");
54 for (const [repo, summary] of [[o.value, "up"], [s.value, "boom"]] as const)
55 app.bus.append({
56 kind: "run.finished",
57 repoId: repo.id,
58 actorId: alice.id,
59 payload: { kind: "deploy", state: "ok", summary, runId: `r-${summary}` },
60 });
61 const items = readFeed(app.db, alice).items.filter((i) => i.id.startsWith("event:"));
62 openEvent = items.find((i) => i.app === "alice/open")!.id;
63 secretEvent = items.find((i) => i.app === "alice/secret")!.id;
64 });
65
66 afterAll(async () => {
67 servers.stop();
68 app.close();
69 await rm(dataDir, { recursive: true, force: true });
70 });
71
72 const like = (tok: string, item: string) =>
73 fetch(`${servers.url}/api/v1/feed/interactions`, {
74 method: "POST",
75 headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" },
76 body: JSON.stringify({ item, kind: "liked" }),
77 });
78 const stateOf = (who: User, item: string) => {
79 const found = readFeed(app.db, who).items.find((i) => i.id === item)!;
80 return { likes: found.likes ?? 0, liked: found.liked ?? false };
81 };
82
83 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 });
86 const first = await like(bobTok, openEvent);
87 expect(first.status).toBe(201);
88 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();
95 });
96
97 test("counts each person once, and shows each viewer their own", async () => {
98 await like(bobTok, openEvent);
99 await like(aliceTok, openEvent);
100 expect(stateOf(bob, openEvent)).toEqual({ likes: 2, liked: true });
101 expect(stateOf(alice, openEvent)).toEqual({ likes: 2, liked: true });
102 await like(bobTok, openEvent);
103 expect(stateOf(bob, openEvent)).toEqual({ likes: 1, liked: false });
104 expect(stateOf(alice, openEvent)).toEqual({ likes: 1, liked: true });
105 await like(aliceTok, openEvent);
106 });
107
108 test("on an item the viewer cannot read is a 404 and leaves no row", async () => {
109 expect((await like(bobTok, secretEvent)).status).toBe(404);
110 expect((await like(bobTok, "event:nothing-here")).status).toBe(404);
111 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);
121 });
122 });
123
124 describe("the Like button", () => {
125 const base = {
126 id: "event:x", kind: "run", app: "alice/open", actor: "alice", at: "2026-01-01T00:00:00Z",
127 link: "/alice/open", what: "Deployed", reason: "because you own alice/open", score: 1,
128 } as const;
129
130 test("is a form posting back to the feed, with the count", () => {
131 const out = String(feedCard({ ...base, likes: 3, liked: false } as never));
132 expect(out).toContain('method="post" action="/feed/like"');
133 expect(out).toContain('name="item" value="event:x"');
134 expect(out).toContain('aria-pressed="false"');
135 expect(out).toContain("<span data-likes>3</span>");
136 });
137
138 test("a post nobody likes still draws the button, at zero", () => {
139 const out = String(feedCard({ ...base } as never));
140 expect(out).toContain("<span data-likes>0</span>");
141 });
142
143 test("without a script the form posts back to the feed", async () => {
144 const res = await fetch(`${servers.url}/feed/like`, {
145 method: "POST",
146 redirect: "manual",
147 headers: { authorization: `Bearer ${bobTok}`, "content-type": "application/x-www-form-urlencoded" },
148 body: new URLSearchParams({ item: openEvent, back: "/feed" }).toString(),
149 });
150 expect(res.status).toBe(303);
151 expect(res.headers.get("location")).toBe("/feed");
152 expect(stateOf(bob, openEvent).liked).toBe(true);
153 const gone = await fetch(`${servers.url}/feed/like`, {
154 method: "POST",
155 headers: { authorization: `Bearer ${bobTok}`, "content-type": "application/x-www-form-urlencoded" },
156 body: new URLSearchParams({ item: secretEvent }).toString(),
157 });
158 expect(gone.status).toBe(404);
159 });
160 });
161
162 describe("the doors", () => {
163 beforeEach(() => void app.db.run("DELETE FROM feed_like"));
164 const cookie = () => `forge_session=${createSession(app.db, bob.id).token}`;
165 const form = (headers: Record<string, string>, item = openEvent) =>
166 fetch(`${servers.url}/feed/like`, {
167 method: "POST",
168 redirect: "manual",
169 headers: { "content-type": "application/x-www-form-urlencoded", ...headers },
170 body: new URLSearchParams({ item, app: "alice/open" }).toString(),
171 });
172 const likesNow = () => app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM feed_like WHERE item_id = ?1").get(openEvent)!.n;
173
174 test("GET /api/v1/feed carries likes and liked for the viewer, and omits them where nobody likes", async () => {
175 await like(aliceTok, openEvent);
176 const get = async (tok: string) =>
177 ((await (await fetch(`${servers.url}/api/v1/feed`, { headers: { authorization: `Bearer ${tok}` } })).json()) as {
178 items: { id: string; likes?: number; liked?: boolean }[];
179 }).items.find((i) => i.id === openEvent)!;
180 expect(await get(aliceTok)).toMatchObject({ likes: 1, liked: true });
181 expect(await get(bobTok)).toMatchObject({ likes: 1, liked: false });
182 await like(aliceTok, openEvent);
183 const none = await get(bobTok);
184 expect(none.likes).toBeUndefined();
185 expect(none.liked).toBeUndefined();
186 });
187
188 test("a cross-origin POST /feed/like is refused and likes nothing", async () => {
189 const before = likesNow();
190 const res = await form({ cookie: cookie(), origin: "http://evil.example" });
191 expect(res.status).toBe(403);
192 expect(likesNow()).toBe(before);
193 });
194
195 test("a signed-out POST /feed/like is sent to sign in and likes nothing", async () => {
196 const before = likesNow();
197 const res = await form({ origin: servers.url });
198 expect(res.status).toBe(303);
199 expect(res.headers.get("location")).toContain("/signin");
200 expect(likesNow()).toBe(before);
201 });
202
203 test("asked for JSON, /feed/like answers the new count", async () => {
204 const on = await form({ authorization: `Bearer ${bobTok}`, accept: "application/json" });
205 expect(await on.json()).toMatchObject({ likes: 1, liked: true });
206 const off = await form({ authorization: `Bearer ${bobTok}`, accept: "application/json" });
207 expect(await off.json()).toMatchObject({ likes: 0, liked: false });
208 });
209 });
210
211 describe("the live script", () => {
212 // The page's own `like`, run against a stub form and a fetch that answers as told.
213 const run = (answer: () => Promise<unknown>) => {
214 const start = feedJs.indexOf("function like(form, e) {");
215 const end = feedJs.indexOf("\n }\n", start) + 4;
216 const state = { pressed: "false", count: "2", painted: 0, read: () => "" };
217 const btn = {
218 getAttribute: () => state.pressed,
219 setAttribute: (_: string, v: string) => void (state.pressed = v),
220 };
221 const count = {
222 get textContent() { return state.count; },
223 set textContent(v: string) { state.count = v; },
224 };
225 const f = {
226 querySelector: (q: string) => (q === "button" ? btn : count),
227 getAttribute: () => "/feed/like",
228 };
229 const script = feedJs.slice(start, end);
230 const made = new Function(
231 "fetch", "FormData", "URLSearchParams", "paint",
232 `var problem = ''; ${script}; return { like: like, get problem() { return problem; } };`,
233 )(answer, function () { return []; }, URLSearchParams, () => void state.painted++) as {
234 like: (f: unknown, e: unknown) => void;
235 problem: string;
236 };
237 const like = made.like;
238 state.read = () => made.problem;
239 like(f, { preventDefault() {} });
240 return state;
241 };
242
243 test("a refusal puts the button and the count back and says so", async () => {
244 const state = run(() => Promise.resolve({ ok: false }));
245 expect(state).toMatchObject({ pressed: "true", count: "3" });
246 await new Promise((r) => setTimeout(r, 0));
247 expect(state).toMatchObject({ pressed: "false", count: "2", painted: 1 });
248 expect(state.read()).toContain("The like was not saved");
249 });
250
251 test("an answer sets the count the forge gave", async () => {
252 const state = run(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ liked: true, likes: 7 }) }));
253 await new Promise((r) => setTimeout(r, 0));
254 expect(state).toMatchObject({ pressed: "true", count: "7" });
255 expect(state.read()).toBe("");
256 });
257 });
test/palette-roster.test.ts +3 −3
@@ -110,7 +110,7 @@ test("a site-admin person is offered the whole roster", () => {
110110 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
111111 ["origins", "Connected origins", "the hosts your mirrors come from"],
112112 ["domains", "Failure domains", "failure domains and their last drill"],
113 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull"],
113 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
114114 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
115115 ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"],
116116 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
@@ -160,7 +160,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
160160 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
161161 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
162162 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
163 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull"],
163 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
164164 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
165165 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
166166 ["family", "Family", "the forge that sent this one, and the forges it sent"],
@@ -210,7 +210,7 @@ test("a member person is offered the signed-in roster without the admin entries"
210210 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
211211 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
212212 ["origins", "Connected origins", "the hosts your mirrors come from"],
213 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull"],
213 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
214214 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
215215 ["profile", "Your profile", "display name and email"],
216216 ["family", "Family", "the forge that sent this one, and the forges it sent"],
test/tui-feed-reply.test.ts +25
@@ -148,3 +148,28 @@ test("a person who cannot read the repository is not shown its post", async () =
148148 expect(listComments(forge.db, issueId).map((c) => c.body)).not.toContain("Let me in.");
149149 t.stop();
150150 });
151
152 test("l likes the post under the cursor, a second l takes it back, and the page's feed sees it", async () => {
153 const alice = getUserByName(forge.db, "alice")!;
154 const t = boot("alice");
155 await t.open();
156 await t.press("l");
157 expect(t.text()).toContain("♥ 1");
158 const [top] = readFeed(forge.db, alice, { limit: 1 }).items;
159 expect(top!.likes).toBe(1);
160 expect(top!.liked).toBe(true);
161 // Another viewer's feed counts it live: the same table the page reads.
162 await t.press("l");
163 expect(t.text()).not.toContain("♥");
164 expect(readFeed(forge.db, alice, { limit: 1 }).items[0]!.likes).toBeUndefined();
165 t.stop();
166 });
167
168 test("l on a post the person cannot read likes nothing", async () => {
169 const t = boot("bob");
170 await t.open();
171 await t.press("l");
172 const secret = forge.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'secret'").get()!.id;
173 expect(forge.db.query("SELECT 1 FROM feed_like WHERE repo_id = ?1").get(secret)).toBeNull();
174 t.stop();
175 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs