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

Merged opened this

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

Merged

scout/forge:scout/5809-a-look-s-feed-post-shows-its-picture-and → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/5809-a-look-s-feed-post-shows-its-picture-and was merged into main as 3583163.

Review

Sign in to review this change.
6 files changed +153 −6
docs/design/areas/feed.md +1
@@ -1,4 +1,5 @@
11 # Feed
22
33 - `.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.
45 - `.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 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { isEnvName, type EnvName } from "../apps/data.ts";
1011 import type { Chips } from "../core/feed_chips.ts";
1112 import { listDecidedIn } from "../core/decisions.ts";
1213 import { audienceFilter } from "../core/event_audience.ts";
@@ -69,6 +70,13 @@ export interface Candidate {
6970 readonly said?: string;
7071 /** For an ask: what it asks, the answers, what happens if nobody acts, and its rank. */
7172 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;
7280 }
7381
7482 export interface FeedAsk {
@@ -470,8 +478,20 @@ function fromEvent(
470478 return { ...common, kind: "pull", link: change, what: `merged a pull request${title ? `: ${clip(title)}` : ""}${toldWords}` };
471479 case "pull.reviewed":
472480 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 }
475495 case "app.serving":
476496 return { ...common, kind: "release", link: base(repo), what: "a new version is serving" };
477497 // 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 @@
77 */
88
99 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";
1111 import type { Settled } from "./settled.ts";
1212
1313 export interface FeedItem {
@@ -25,6 +25,8 @@ export interface FeedItem {
2525 readonly score: number;
2626 /** For an ask: the decision, options, default and rank. */
2727 readonly ask?: FeedAsk;
28 /** For a look: which picture it is of. */
29 readonly look?: FeedLook;
2830 /** For a post about a pull or an issue that is over: how it ended. Absent while open. */
2931 readonly settled?: Settled;
3032 }
@@ -153,6 +155,7 @@ export function rank(
153155 reason: reasonOf(s!.c, aff, now),
154156 score: Math.round(bestScore * 1e6) / 1e6,
155157 ...(s!.c.ask === undefined ? {} : { ask: s!.c.ask }),
158 ...(s!.c.look === undefined ? {} : { look: s!.c.look }),
156159 });
157160 }
158161 return out;
src/web/studio/feed.ts +45 −3
@@ -17,6 +17,10 @@ import { placementsOf, type Placement } from "../../core/placements.ts";
1717 import { recentThoughts, type Thought } from "../../core/thoughts.ts";
1818 import { parseCursor, readFeed } from "../../feed/feed.ts";
1919 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";
2024 import { html, type Html } from "../html.ts";
2125 import { fragment } from "../layout.ts";
2226 import { outboxJs } from "../outbox.ts";
@@ -55,6 +59,38 @@ const saysMore = (item: FeedItem): boolean =>
5559 !item.reason.startsWith(item.what) &&
5660 !/^because you (own|work on|pinned) /.test(item.reason);
5761
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");

Per-item repo, app and looks lookups. Memoise by item.app inside this loop.

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
5894 /** How a pull or an issue ended, as the quiet word beside a post's time. */
5995 function settledBadge(settled: FeedItem["settled"]): Html | null {
6096 if (settled === undefined) return null;
@@ -69,6 +105,7 @@ function settledBadge(settled: FeedItem["settled"]): Html | null {
69105 export function feedCard(
70106 item: FeedItem,
71107 marks: ReadonlyMap<string, Mark> = new Map(),
108 picture?: FeedPicture,
72109 ): Html {
73110 const steer = item.kind === "run" || item.kind === "cost";
74111 const target = steer && RUN_LINK.test(item.link) ? `${item.link}/steer` : null;
@@ -80,6 +117,11 @@ export function feedCard(
80117 <span class="spacer"></span>
81118 <span class="badge">${KIND_LABEL[item.kind]}</span>`;
82119 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>`}
83125 ${saysMore(item) ? html`<p class="small muted break">${item.reason}</p>` : null}`;
84126 const foot = disclosure(
85127 `${target ? "Steer" : "Tell"} · more`,
@@ -265,7 +307,7 @@ function readRegion(user: User, ctx: Ctx) {
265307 ctx.app.db,
266308 items.map((item) => item.actor ?? ""),
267309 );
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) };
269311 }
270312
271313 /** 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
293335 }
294336
295337 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;
297339 return html`<section
298340 id="live-feed"
299341 class="feed"
@@ -309,7 +351,7 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>):
309351 title: "Nothing new",
310352 note: "When something happens in your apps, it lands here.",
311353 })
312 : items.map((item) => feedCard(item, marks))}
354 : items.map((item) => feedCard(item, marks, pictures.get(item.id)))}
313355 ${caughtUp(items, next, cursor)}
314356 </section>`;
315357 }
src/web/style/components.ts +2
@@ -367,6 +367,8 @@ a.sha:hover { color: var(--accent); }
367367 .look img { display: block; width: 100%; height: auto; }
368368 .look[data-slot="thumb"] { flex: none; width: 30px; height: 40px; border-radius: 4px; }
369369 .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; }
370372 .look[data-slot="now"] { flex: none; width: 168px; }
371373 .looks { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }
372374 .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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs