Public: anyone can read this; the crew can change it.
A look's feed post shows its picture and names the pull it previews, linking to the look #5845
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5809-a-look-s-feed-post-shows-its-picture-and → main 2 commits fork 3 threads · 3 unresolved
6 files
changed +153
−6
docs/design/areas/feed.md +1
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | # Feed |
| 2 | 2 | |
| 3 | 3 | - `.feed-head` — the head of the home's feed: a §5 `.bar` above the timeline holding the "What you asked for" link, pushed to the end by a spacer. Drawn by `studioHome` in `src/web/studio/home.ts`, styled in `src/web/studio/feed_style.ts`. Under 900px it is hidden, since that link is a tab on a phone and the first post should come next; a bare `.bar` cannot carry this, as it has no reason to vanish on a narrow screen. |
| 4 | - `.look[data-slot="feed"]` — a look post's thumbnail, drawn by `feedCard` in `src/web/studio/feed.ts`: the §5 `.look` frame fixed at 160×100 (the img carries the same width and height, so nothing shifts), cropped from the top like the other thumbs. A bare `.look` is fluid and would resize the post. | |
| 4 | 5 | - `.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`. |
src/feed/items.ts +22 −2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { isEnvName, type EnvName } from "../apps/data.ts"; | |
| 10 | 11 | import type { Chips } from "../core/feed_chips.ts"; |
| 11 | 12 | import { listDecidedIn } from "../core/decisions.ts"; |
| 12 | 13 | import { audienceFilter } from "../core/event_audience.ts"; |
| @@ -69,6 +70,13 @@ export interface Candidate { | ||
| 69 | 70 | readonly said?: string; |
| 70 | 71 | /** For an ask: what it asks, the answers, what happens if nobody acts, and its rank. */ |
| 71 | 72 | readonly ask?: FeedAsk; |
| 73 | /** For a look: the picture's environment and commit; the page finds the thumbnail. */ | |
| 74 | readonly look?: FeedLook; | |
| 75 | } | |
| 76 | ||
| 77 | export interface FeedLook { | |
| 78 | readonly env: EnvName; | |
| 79 | readonly commitOid: string; | |
| 72 | 80 | } |
| 73 | 81 | |
| 74 | 82 | export interface FeedAsk { |
| @@ -470,8 +478,20 @@ function fromEvent( | ||
| 470 | 478 | return { ...common, kind: "pull", link: change, what: `merged a pull request${title ? `: ${clip(title)}` : ""}${toldWords}` }; |
| 471 | 479 | case "pull.reviewed": |
| 472 | 480 | return { ...common, kind: "pull", link: change, what: `reviewed a pull request${title ? `: ${clip(title)}` : ""}` }; |
| 473 | case "look.taken": | |
| 474 | return { ...common, kind: "look", link: base(repo), what: "a new picture of it is ready" }; | |
| 481 | case "look.taken": { | |
| 482 | const env = p["env"]; | |
| 483 | const oid = p["commitOid"]; | |
| 484 | if (typeof env !== "string" || !isEnvName(env)) | |
| 485 | return { ...common, kind: "look", link: base(repo), what: "a new picture of it is ready" }; | |
| 486 | const of = env === "production" ? "production" : `#${env.slice("pull-".length)}`; | |
| 487 | return { | |
| 488 | ...common, | |
| 489 | kind: "look", | |
| 490 | link: env === "production" ? base(repo) : `${base(repo)}/pulls/${env.slice("pull-".length)}`, | |
| 491 | what: `a new picture of ${of}`, | |
| 492 | ...(typeof oid === "string" && /^[0-9a-f]{40}$/.test(oid) ? { look: { env, commitOid: oid } } : {}), | |
| 493 | }; | |
| 494 | } | |
| 475 | 495 | case "app.serving": |
| 476 | 496 | return { ...common, kind: "release", link: base(repo), what: "a new version is serving" }; |
| 477 | 497 | // A tell is a post; a comment is a reply under it, in its own words. |
src/feed/rank.ts +4 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { decay, type Affinity, type Weighted, type Why } from "./affinity.ts"; |
| 10 | import type { Candidate, FeedAsk } from "./items.ts"; | |
| 10 | import type { Candidate, FeedAsk, FeedLook } from "./items.ts"; | |
| 11 | 11 | import type { Settled } from "./settled.ts"; |
| 12 | 12 | |
| 13 | 13 | export interface FeedItem { |
| @@ -25,6 +25,8 @@ export interface FeedItem { | ||
| 25 | 25 | readonly score: number; |
| 26 | 26 | /** For an ask: the decision, options, default and rank. */ |
| 27 | 27 | readonly ask?: FeedAsk; |
| 28 | /** For a look: which picture it is of. */ | |
| 29 | readonly look?: FeedLook; | |
| 28 | 30 | /** For a post about a pull or an issue that is over: how it ended. Absent while open. */ |
| 29 | 31 | readonly settled?: Settled; |
| 30 | 32 | } |
| @@ -153,6 +155,7 @@ export function rank( | ||
| 153 | 155 | reason: reasonOf(s!.c, aff, now), |
| 154 | 156 | score: Math.round(bestScore * 1e6) / 1e6, |
| 155 | 157 | ...(s!.c.ask === undefined ? {} : { ask: s!.c.ask }), |
| 158 | ...(s!.c.look === undefined ? {} : { look: s!.c.look }), | |
| 156 | 159 | }); |
| 157 | 160 | } |
| 158 | 161 | return out; |
src/web/studio/feed.ts +45 −3
| @@ -17,6 +17,10 @@ import { placementsOf, type Placement } from "../../core/placements.ts"; | ||
| 17 | 17 | import { recentThoughts, type Thought } from "../../core/thoughts.ts"; |
| 18 | 18 | import { parseCursor, readFeed } from "../../feed/feed.ts"; |
| 19 | 19 | import type { FeedItem } from "../../feed/rank.ts"; |
| 20 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 21 | import { getRepo } from "../../core/repos.ts"; | |
| 22 | import { DEFAULT_PAGE, looksForEnv, lookUrl } from "../../core/looks.ts"; | |
| 23 | import { mayFetchLook, pictured } from "../pages/looks.ts"; | |
| 20 | 24 | import { html, type Html } from "../html.ts"; |
| 21 | 25 | import { fragment } from "../layout.ts"; |
| 22 | 26 | import { outboxJs } from "../outbox.ts"; |
| @@ -55,6 +59,38 @@ const saysMore = (item: FeedItem): boolean => | ||
| 55 | 59 | !item.reason.startsWith(item.what) && |
| 56 | 60 | !/^because you (own|work on|pinned) /.test(item.reason); |
| 57 | 61 | |
| 62 | /** A look post's thumbnail: its address and the size it is drawn at, so nothing shifts. */ | |
| 63 | export interface FeedPicture { | |
| 64 | readonly src: string; | |
| 65 | readonly width: number; | |
| 66 | readonly height: number; | |
| 67 | } | |
| 68 | ||
| 69 | /** The thumbnail is drawn at 160×100, the thumb's 8:5. */ | |
| 70 | export const FEED_PICTURE = { width: 160, height: 100 } as const; | |
| 71 | ||
| 72 | /** | |
| 73 | * The thumbnail each look post shows, by post id — only one this viewer | |
| 74 | * may fetch (`mayFetchLook`), so a post never carries a broken image. | |
| 75 | */ | |
| 76 | export function picturesFor(ctx: Ctx, items: readonly FeedItem[]): Map<string, FeedPicture> { | |
| 77 | const out = new Map<string, FeedPicture>(); | |
| 78 | for (const item of items) { | |
| 79 | if (item.kind !== "look" || item.look === undefined) continue; | |
| 80 | const [owner = "", name = ""] = item.app.split("/"); | |
| 81 | const repo = getRepo(ctx.app.db, owner, name); | |
| 82 | const record = repo && getAppByRepo(ctx.app.db, repo.id); | |
| 83 | if (!repo || !record) continue; | |
| 84 | const home = looksForEnv(ctx.app.db, record.id, item.look.env).filter( | |
| 85 | (l) => l.page === DEFAULT_PAGE && l.commitOid === item.look!.commitOid, | |
| 86 | ); | |
| 87 | const thumb = pictured(home, "thumb"); | |
| 88 | if (thumb === undefined || !mayFetchLook(ctx, repo, record, thumb)) continue; | |
| 89 | out.set(item.id, { src: lookUrl(`/${repo.ownerName}/${repo.name}`, thumb), ...FEED_PICTURE }); | |
| 90 | } | |
| 91 | return out; | |
| 92 | } | |
| 93 | ||
| 58 | 94 | /** How a pull or an issue ended, as the quiet word beside a post's time. */ |
| 59 | 95 | function settledBadge(settled: FeedItem["settled"]): Html | null { |
| 60 | 96 | if (settled === undefined) return null; |
| @@ -69,6 +105,7 @@ function settledBadge(settled: FeedItem["settled"]): Html | null { | ||
| 69 | 105 | export function feedCard( |
| 70 | 106 | item: FeedItem, |
| 71 | 107 | marks: ReadonlyMap<string, Mark> = new Map(), |
| 108 | picture?: FeedPicture, | |
| 72 | 109 | ): Html { |
| 73 | 110 | const steer = item.kind === "run" || item.kind === "cost"; |
| 74 | 111 | const target = steer && RUN_LINK.test(item.link) ? `${item.link}/steer` : null; |
| @@ -80,6 +117,11 @@ export function feedCard( | ||
| 80 | 117 | <span class="spacer"></span> |
| 81 | 118 | <span class="badge">${KIND_LABEL[item.kind]}</span>`; |
| 82 | 119 | const body = html`<p class="break"><a href="${item.link}" data-feed-open>${item.what}</a></p> |
| 120 | ${picture === undefined | |
| 121 | ? null | |
| 122 | : html`<a class="look" data-slot="feed" href="${item.link}" tabindex="-1" aria-hidden="true" | |
| 123 | ><img src="${picture.src}" width="${picture.width}" height="${picture.height}" alt="" loading="lazy" decoding="async" | |
| 124 | /></a>`} | |
| 83 | 125 | ${saysMore(item) ? html`<p class="small muted break">${item.reason}</p>` : null}`; |
| 84 | 126 | const foot = disclosure( |
| 85 | 127 | `${target ? "Steer" : "Tell"} · more`, |
| @@ -265,7 +307,7 @@ function readRegion(user: User, ctx: Ctx) { | ||
| 265 | 307 | ctx.app.db, |
| 266 | 308 | items.map((item) => item.actor ?? ""), |
| 267 | 309 | ); |
| 268 | return { items, next: page.next_cursor, cursor, thoughts, placed, marks }; | |
| 310 | return { items, next: page.next_cursor, cursor, thoughts, placed, marks, pictures: picturesFor(ctx, items) }; | |
| 269 | 311 | } |
| 270 | 312 | |
| 271 | 313 | /** The region: cards, or one line saying there is nothing to do. */ |
| @@ -293,7 +335,7 @@ export function caughtUp(items: readonly FeedItem[], next: string | null, cursor | ||
| 293 | 335 | } |
| 294 | 336 | |
| 295 | 337 | function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>): Html { |
| 296 | const { items, next, cursor, thoughts, placed, marks } = read; | |
| 338 | const { items, next, cursor, thoughts, placed, marks, pictures } = read; | |
| 297 | 339 | return html`<section |
| 298 | 340 | id="live-feed" |
| 299 | 341 | class="feed" |
| @@ -309,7 +351,7 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>): | ||
| 309 | 351 | title: "Nothing new", |
| 310 | 352 | note: "When something happens in your apps, it lands here.", |
| 311 | 353 | }) |
| 312 | : items.map((item) => feedCard(item, marks))} | |
| 354 | : items.map((item) => feedCard(item, marks, pictures.get(item.id)))} | |
| 313 | 355 | ${caughtUp(items, next, cursor)} |
| 314 | 356 | </section>`; |
| 315 | 357 | } |
src/web/style/components.ts +2
| @@ -367,6 +367,8 @@ a.sha:hover { color: var(--accent); } | ||
| 367 | 367 | .look img { display: block; width: 100%; height: auto; } |
| 368 | 368 | .look[data-slot="thumb"] { flex: none; width: 30px; height: 40px; border-radius: 4px; } |
| 369 | 369 | .look[data-slot="thumb"] img { height: 100%; object-fit: cover; object-position: top; } |
| 370 | .look[data-slot="feed"] { width: 160px; height: 100px; margin-top: 6px; } | |
| 371 | .look[data-slot="feed"] img { height: 100%; object-fit: cover; object-position: top; } | |
| 370 | 372 | .look[data-slot="now"] { flex: none; width: 168px; } |
| 371 | 373 | .looks { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; } |
| 372 | 374 | .looks > [data-slot="phone"] { flex: 0 0 200px; } |
test/feed-look-post.test.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** A look's feed post names the pull it previews, links to it, and shows its thumbnail at a fixed size (#5809). */ | |
The header cites #5809, but this is #5845. Correct the reference. |
||
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 9 | import { recordLook } from "../src/core/looks.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { readFeed } from "../src/feed/feed.ts"; | |
| 12 | import { feedCard, FEED_PICTURE, picturesFor } from "../src/web/studio/feed.ts"; | |
| 13 | import { proveAll } from "./helpers/verified.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | const PULL_OID = "b".repeat(40); | |
| 19 | const PROD_OID = "c".repeat(40); | |
| 20 | let root = ""; | |
| 21 | let app: App; | |
| 22 | let alice: User; | |
| 23 | let repoId = ""; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | root = mkdtempSync(join(tmpdir(), "forge-feed-look-")); | |
| 27 | app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" }); | |
| 28 | const u = await createUser(app.db, { | |
| 29 | origin: "seed", | |
| 30 | trusted: true, | |
| 31 | username: "alice", | |
| 32 | email: "[email protected]", | |
| 33 | password: "a-good-passphrase", | |
| 34 | adminIfFirst: true, | |
| 35 | }); | |
| 36 | if (!u.ok) throw new Error(u.error); | |
| 37 | alice = u.value; | |
| 38 | proveAll(app); | |
| 39 | const made = await createRepo(app, alice, { name: "forge" }); | |
| 40 | if (!made.ok) throw new Error(made.error); | |
| 41 | repoId = made.value.id; | |
| 42 | const record = insertApp(app.db, { repoId, slug: "garden", access: "public" }); | |
| 43 | if (!record.ok) throw new Error(record.error); | |
| 44 | for (const [env, oid] of [["pull-5736", PULL_OID], ["production", PROD_OID]] as const) { | |
| 45 | const path = `looks/${record.value.id}/${env}/${oid}-thumb.png`; | |
| 46 | mkdirSync(join(root, "looks", record.value.id, env), { recursive: true }); | |
| 47 | writeFileSync(join(root, path), "png"); | |
| 48 | recordLook(app.db, { appId: record.value.id, env, commitOid: oid, kind: "thumb", path, takenAs: "nobody" }); | |
| 49 | app.bus.append({ kind: "look.taken", repoId, payload: { env, commitOid: oid } }); | |
| 50 | } | |
| 51 | }); | |
| 52 | afterAll(() => { | |
| 53 | app.close(); | |
| 54 | rmSync(root, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | const posts = () => { | |
| 58 | const items = readFeed(app.db, alice, { limit: 20 }).items.filter((i) => i.kind === "look"); | |
| 59 | const ctx = { app, auth: { user: alice } } as never; | |
| 60 | const pictures = picturesFor(ctx, items); | |
| 61 | return items.map((i) => ({ item: i, text: feedCard(i, new Map(), pictures.get(i.id)).text })); | |
| 62 | }; | |
| 63 | ||
| 64 | test("a pull's look names the pull, links to it and shows a fixed-size lazy thumbnail", () => { | |
| 65 | const post = posts().find((p) => p.item.what === "a new picture of #5736"); | |
| 66 | expect(post).toBeDefined(); | |
| 67 | expect(post!.item.link).toBe("/alice/forge/pulls/5736"); | |
| 68 | expect(post!.text).toContain('href="/alice/forge/pulls/5736"'); | |
| 69 | expect(post!.text).toContain(`/alice/forge/looks/pull-5736/${PULL_OID}/thumb.png?v=`); | |
| 70 | expect(post!.text).toContain(`width="${FEED_PICTURE.width}" height="${FEED_PICTURE.height}"`); | |
| 71 | expect(post!.text).toContain('loading="lazy"'); | |
Add a case where mayFetchLook is false (private app, guest viewer) and assert no <img> is drawn. |
||
| 72 | }); | |
| 73 | ||
| 74 | test("a look of production says production", () => { | |
| 75 | const post = posts().find((p) => p.item.what === "a new picture of production"); | |
| 76 | expect(post).toBeDefined(); | |
| 77 | expect(post!.item.link).toBe("/alice/forge"); | |
| 78 | expect(post!.text).toContain(`/looks/production/${PROD_OID}/thumb.png`); | |
| 79 | }); |
new above
offline
Per-item repo, app and looks lookups. Memoise by item.app inside this loop.