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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7751-a-person-likes-a-post-in-the-feed-one-pr → main 5 commits fork
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 | ||
| 172 | 172 | script (no new script, no dependency). Not covered: the "the forge" thought |
| 173 | 173 | target posts as a plain form, so its draft is dropped on submit, and the |
| 174 | 174 | 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 @@ | ||
| 4 | 4 | - `.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`. |
| 5 | 5 | - 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. |
| 6 | 6 | - 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"; | ||
| 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; 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 }", | |
| 7 | 7 | auth: true, |
| 8 | 8 | status: 201, |
| 9 | 9 | 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"; | ||
| 19 | 19 | import { Stamped } from "../kernel/stamped.ts"; |
| 20 | 20 | import { foldBursts } from "./fold.ts"; |
| 21 | 21 | import { rank, type FeedItem } from "./rank.ts"; |
| 22 | import { likesOf } from "./likes.ts"; | |
| 22 | 23 | import { stepsOf } from "./step.ts"; |
| 23 | 24 | import { settledOf } from "./settled.ts"; |
| 24 | 25 | |
| @@ -202,10 +203,17 @@ export function readFeedPage( | ||
| 202 | 203 | const settled = settledOf(db, page); |
| 203 | 204 | options.phase?.("settled", performance.now() - started); |
| 204 | 205 | const steps = stepsOf(db, page); |
| 206 | const likes = likesOf(db, viewer.id, page.map((i) => i.id)); | |
| 205 | 207 | const items = page.map((i) => { |
| 206 | 208 | const state = settled.get(i.id); |
| 207 | 209 | 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 | }; | |
| 209 | 217 | }); |
| 210 | 218 | const last = items[items.length - 1]; |
| 211 | 219 | return { |
src/feed/interact.ts +5 −5
| @@ -47,9 +47,9 @@ export interface InteractionRefusal { | ||
| 47 | 47 | readonly message: string; |
| 48 | 48 | } |
| 49 | 49 | |
| 50 | export interface InteractionAct { | |
| 50 | export interface InteractionAct<K extends string = InteractionKind> { | |
| 51 | 51 | readonly itemId: string; |
| 52 | readonly kind: InteractionKind; | |
| 52 | readonly kind: K; | |
| 53 | 53 | readonly target: InteractionTarget; |
| 54 | 54 | } |
| 55 | 55 | |
| @@ -63,12 +63,12 @@ const text = (v: unknown): string => (typeof v === "string" ? v.trim() : ""); | ||
| 63 | 63 | * a feed item id, the kind is one of `kinds` (the door's own), and the |
| 64 | 64 | * viewer may act on what the item is about. |
| 65 | 65 | */ |
| 66 | export function readInteraction( | |
| 66 | export function readInteraction<K extends string = InteractionKind>( | |
| 67 | 67 | db: Database, |
| 68 | 68 | viewer: User, |
| 69 | 69 | 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> { | |
| 72 | 72 | const itemId = text(input.item); |
| 73 | 73 | if (!ITEM_ID.test(itemId)) |
| 74 | 74 | 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 { | ||
| 33 | 33 | readonly look?: FeedLook; |
| 34 | 34 | /** For a post about a pull or an issue that is over: how it ended. Absent while open. */ |
| 35 | 35 | 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; | |
| 36 | 39 | /** For a post about a pull: which of check, review, land, live it is at, and why. */ |
| 37 | 40 | readonly step?: PathStep; |
| 38 | 41 | } |
src/http/feed.ts +7 −1
| @@ -13,6 +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 | 17 | import { |
| 17 | 18 | ITEM_ID, |
| 18 | 19 | INTERACTION_KINDS, |
| @@ -55,6 +56,9 @@ import { | ||
| 55 | 56 | unauthorized, |
| 56 | 57 | } from "./respond.ts"; |
| 57 | 58 | |
| 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 | ||
| 58 | 62 | export function feedRoutes(router: Router<Handler>): void { |
| 59 | 63 | router.add("GET", "/api/v1/feed", (ctx) => { |
| 60 | 64 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -232,10 +236,12 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 232 | 236 | const viewer = ctx.auth.user!; |
| 233 | 237 | const db = ctx.app.db; |
| 234 | 238 | const input = await body(ctx.request); |
| 235 | const act = readInteraction(db, viewer, input, INTERACTION_KINDS); | |
| 239 | const act = readInteraction(db, viewer, input, DOOR_KINDS); | |
| 236 | 240 | if (!act.ok) |
| 237 | 241 | return apiError(act.error.code, act.error.code === "not_found" ? 404 : 422, act.error.message); |
| 238 | 242 | 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); | |
| 239 | 245 | const made = recordInteraction(db, { |
| 240 | 246 | viewerId: viewer.id, |
| 241 | 247 | itemId, |
src/http/server.ts +3
| @@ -295,6 +295,7 @@ import { | ||
| 295 | 295 | import { keeperAnswer } from "../web/studio/keeper_row.ts"; |
| 296 | 296 | import { homeArchive, homeArchived, homeArchiveUndo } from "../web/studio/home_archive.ts"; |
| 297 | 297 | import { liveFeedFragment } from "../web/studio/feed.ts"; |
| 298 | import { likeSubmit } from "../web/studio/like.ts"; | |
| 298 | 299 | import { liveChipsFragment, liveMovingFragment } from "../web/studio/living.ts"; |
| 299 | 300 | import { HOME_REGIONS } from "../web/studio/home_last.ts"; |
| 300 | 301 | import { serviceWorker } from "../web/sw.ts"; |
| @@ -1231,6 +1232,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1231 | 1232 | router.add("POST", "/:owner/:name/tell", tellSubmit); |
| 1232 | 1233 | // The home's composer without a script: a tell to the app it names (#599). |
| 1233 | 1234 | 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); | |
| 1234 | 1237 | router.add("POST", "/:owner/:name/pulls/:number/checks", pullChecksSubmit); |
| 1235 | 1238 | router.add("POST", "/:owner/:name/pulls/:number/ship", shipSubmit); |
| 1236 | 1239 | router.add("POST", "/:owner/:name/pulls/:number/say", saySubmit); |
src/tui/screens/feed.ts +29 −4
| @@ -4,7 +4,9 @@ | ||
| 4 | 4 | * |
| 5 | 5 | * A reply is a comment on the issue or pull the post links to: the same |
| 6 | 6 | * `addCommentThrough` the issue and pull screens call, offered on the same |
| 7 | * posts to the same people as the page's Reply (`feed/reply.ts`). Twenty | |
| 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 | |
| 8 | 10 | * posts at a time; `n` reads the next page from the cursor the last returned. |
| 9 | 11 | */ |
| 10 | 12 | |
| @@ -12,6 +14,8 @@ import { addCommentThrough } from "../../actions/issues.ts"; | ||
| 12 | 14 | import { getIssue } from "../../core/issues.ts"; |
| 13 | 15 | import { parseCursor, readFeed } from "../../feed/feed.ts"; |
| 14 | 16 | import type { FeedItem } from "../../feed/rank.ts"; |
| 17 | import { readInteraction } from "../../feed/interact.ts"; | |
| 18 | import { toggleLike } from "../../feed/likes.ts"; | |
| 15 | 19 | import { replyTarget } from "../../feed/reply.ts"; |
| 16 | 20 | import type { Ctx, Screen } from "../app/app.ts"; |
| 17 | 21 | import { binding } from "../app/keys.ts"; |
| @@ -27,6 +31,10 @@ const PAGE = 20; | ||
| 27 | 31 | |
| 28 | 32 | const oneLine = (text: string): string => text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " "); |
| 29 | 33 | |
| 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 | ||
| 30 | 38 | export function feedScreen(f: Session, ctx: Ctx): Screen { |
| 31 | 39 | const state = new ListState(); |
| 32 | 40 | const composer = new Composer(); |
| @@ -49,6 +57,21 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 49 | 57 | return item === undefined ? undefined : replyTarget(f.db, f.user, item); |
| 50 | 58 | }; |
| 51 | 59 | |
| 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 | ||
| 52 | 75 | const submit = (): void => { |
| 53 | 76 | const at = selected(); |
| 54 | 77 | if (at === undefined) return; |
| @@ -72,6 +95,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 72 | 95 | return [ |
| 73 | 96 | binding(["j", "k"], "move"), |
| 74 | 97 | ...(selected() === undefined ? [] : [binding(["r"], "reply")]), |
| 98 | ...(items.length === 0 ? [] : [binding(["l"], "like")]), | |
| 75 | 99 | ...(next === null ? [] : [binding(["n"], "next page")]), |
| 76 | 100 | binding(["esc", "q"], "back"), |
| 77 | 101 | ]; |
| @@ -86,7 +110,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 86 | 110 | const rows: ListRow[] = items.map((item) => ({ |
| 87 | 111 | title: oneLine(`${item.actor ?? item.app} ${item.what}`), |
| 88 | 112 | secondary: item.app, |
| 89 | meta: ago(item.at), | |
| 113 | meta: [likeMark(item), ago(item.at)].filter((m) => m !== "").join(" "), | |
| 90 | 114 | ...(item.step === undefined ? {} : { note: stepLine(item.step) }), |
| 91 | 115 | })); |
| 92 | 116 | drawList(s, list!, c, state, rows); |
| @@ -104,7 +128,8 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 104 | 128 | else if (e.key === "r") { |
| 105 | 129 | const at = selected(); |
| 106 | 130 | 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") { | |
| 108 | 133 | if (next !== null) read(next); |
| 109 | 134 | } else state.key(e, items.length); |
| 110 | 135 | return true; |
| @@ -116,7 +141,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 116 | 141 | export const entry: PaletteEntry = { |
| 117 | 142 | id: "feed", |
| 118 | 143 | 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", | |
| 120 | 145 | offered: (session) => session.user !== undefined, |
| 121 | 146 | run: (session, ctx) => ctx.push(feedScreen(session, ctx)), |
| 122 | 147 | }; |
src/web/studio/feed.ts +38 −1
| @@ -102,6 +102,24 @@ function settledBadge(settled: FeedItem["settled"]): Html | null { | ||
| 102 | 102 | return stateBadge(settled === "merged" ? "merged" : "closed", settled === "parked"); |
| 103 | 103 | } |
| 104 | 104 | |
| 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 | ||
| 105 | 123 | /** |
| 106 | 124 | * One post: who, where and when; what it says, as a link into the thing; |
| 107 | 125 | * why, when that is news; and one quiet control for the rest — say |
| @@ -136,7 +154,8 @@ export function feedCard( | ||
| 136 | 154 | ><img src="${picture.src}" width="${picture.width}" height="${picture.height}" alt="" loading="lazy" decoding="async" |
| 137 | 155 | /></a>`} |
| 138 | 156 | ${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)}`; | |
| 140 | 159 | const foot = disclosure( |
| 141 | 160 | `${reply ? "Reply or " : ""}${target ? "Steer" : "Tell"} · more`, |
| 142 | 161 | html`${reply |
| @@ -564,11 +583,29 @@ ${dayKeepJs} | ||
| 564 | 583 | if (t.hasAttribute('data-feed-open')) { send(card, 'opened'); return; } |
| 565 | 584 | act(card, t.getAttribute('data-feed')); |
| 566 | 585 | }); |
| 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 | } | |
| 567 | 603 | // A tell is kept and sent the same way; a steer needs the run, so it |
| 568 | 604 | // stays an ordinary post and waits for the network. |
| 569 | 605 | feed.addEventListener('submit', function (e) { |
| 570 | 606 | var form = e.target; |
| 571 | 607 | var card = form.closest && form.closest('[data-item]'); |
| 608 | if (card && form.hasAttribute && form.hasAttribute('data-like')) { like(form, e); return; } | |
| 572 | 609 | var box = form.elements && form.elements.text; |
| 573 | 610 | if (!card || !box || !/\\/tell$/.test(form.getAttribute('action') || '')) return; |
| 574 | 611 | var text = box.value.trim(); |
src/web/studio/feed_style.ts +3
| @@ -5,6 +5,9 @@ export const feedCss = /* css */ ` | ||
| 5 | 5 | .feed { display: grid; gap: 0; } |
| 6 | 6 | .feed > .card:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } |
| 7 | 7 | .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; } | |
| 8 | 11 | .feed .card-foot > details { flex: 1 1 100%; } |
| 9 | 12 | /* Your apps: a narrow column on a desk, a tab away on a phone. */ |
| 10 | 13 | /* 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", () => { | ||
| 110 | 110 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 111 | 111 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 112 | 112 | ["domains", "Failure domains", "failure domains and their last drill"], |
| 113 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull"], | |
| 113 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"], | |
| 114 | 114 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 115 | 115 | ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"], |
| 116 | 116 | ["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", | ||
| 160 | 160 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 161 | 161 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 162 | 162 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 163 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull"], | |
| 163 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"], | |
| 164 | 164 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 165 | 165 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 166 | 166 | ["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" | ||
| 210 | 210 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 211 | 211 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 212 | 212 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 213 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull"], | |
| 213 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"], | |
| 214 | 214 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 215 | 215 | ["profile", "Your profile", "display name and email"], |
| 216 | 216 | ["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 () = | ||
| 148 | 148 | expect(listComments(forge.db, issueId).map((c) => c.body)).not.toContain("Let me in."); |
| 149 | 149 | t.stop(); |
| 150 | 150 | }); |
| 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 | }); |
new above
offline