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

The home timeline ends its first page with "You're caught up" and the time of the oldest post shown, linking to older posts #5989

Merged opened this

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

Merged

scout/forge:scout/5980-the-home-timeline-ends-its-first-page-wi → main 3 commits fork

  • Merged. scout/5980-the-home-timeline-ends-its-first-page-wi was merged into main as 30cf8f4.

Review

Sign in to review this change.
2 files changed +125 −8
src/web/studio/feed.ts +32 −8
@@ -15,12 +15,12 @@ import type { Ctx } from "../../http/context.ts";
1515 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1616 import { placementsOf, type Placement } from "../../core/placements.ts";
1717 import { recentThoughts, type Thought } from "../../core/thoughts.ts";
18 import { readFeed } from "../../feed/feed.ts";
18 import { parseCursor, readFeed } from "../../feed/feed.ts";
1919 import type { FeedItem } from "../../feed/rank.ts";
2020 import { html, type Html } from "../html.ts";
2121 import { fragment } from "../layout.ts";
2222 import { outboxJs } from "../outbox.ts";
23 import { avatar, btn, card, disclosure, empty, nameMark, stateBadge, timeAgo } from "../ui.ts";
23 import { avatar, btn, card, disclosure, empty, nameMark, stateBadge, time, timeAgo } from "../ui.ts";
2424 import { writableAppsOf } from "./asks.ts";
2525
2626 /** Events that can change what the feed says. */
@@ -249,11 +249,15 @@ function chipsBar(user: User, ctx: Ctx, items: readonly FeedItem[]): Html {
249249 function readRegion(user: User, ctx: Ctx) {
250250 // What needs you is pinned above the timeline (`needsYouSection`), so an
251251 // ask is never a second time a post below it; the feed API keeps them.
252 const items = readFeed(ctx.app.db, user, {
252 // `?cursor=` is a page the line below the last post pointed at; one not issued here is the first page.
253 const cursor = parseCursor(ctx.url.searchParams.get("cursor") ?? "");
254 const page = readFeed(ctx.app.db, user, {
253255 limit: FEED_FIRST_PAGE,
254256 withoutAsks: true,
255257 phase: (name, ms) => notePhase(ctx.request, name, ms),
256 }).items;
258 ...(cursor === undefined ? {} : { cursor }),
259 });
260 const items = page.items;
257261 const thoughts = recentThoughts(ctx.app.db, user.id, THOUGHTS_SHOWN, new Date(Date.now() - THOUGHT_AGE_MS).toISOString());
258262 const placed = placementsOf(ctx.app.db, thoughts.map((t) => t.id));
259263 // Who each poster is, asked once for the page (`core/marks.ts`).
@@ -261,7 +265,7 @@ function readRegion(user: User, ctx: Ctx) {
261265 ctx.app.db,
262266 items.map((item) => item.actor ?? ""),
263267 );
264 return { items, thoughts, placed, marks };
268 return { items, next: page.next_cursor, cursor, thoughts, placed, marks };
265269 }
266270
267271 /** The region: cards, or one line saying there is nothing to do. */
@@ -269,14 +273,33 @@ export function feedRegion(user: User, ctx: Ctx): Html {
269273 return drawRegion(user, ctx, readRegion(user, ctx));
270274 }
271275
276 /**
277 * The line under the last post: how far back the page reaches and the way
278 * to older ones, or that there is nothing older.
279 */
280 export function caughtUp(items: readonly FeedItem[], next: string | null, cursor?: unknown): Html | null {
281 // Ranked by score, not time: the last card is not always the oldest.
282 const oldest = items.reduce<string | undefined>((min, item) => (min === undefined || item.at < min ? item.at : min), undefined);
283 if (oldest === undefined) return null;
284 const newest = cursor === undefined ? html`` : html`<a href="/">Newest</a>`;
285 return next === null
286 ? html`<p class="bar muted small" data-caught-up="end"><span>That's everything</span><span class="spacer"></span>${newest}</p>`
287 : html`<p class="bar muted small" data-caught-up="more">
288 <span>You're caught up to ${time(oldest)}</span>
289 <span class="spacer"></span>
290 ${newest}
291 <a href="/?cursor=${encodeURIComponent(next)}">Older posts</a>
292 </p>`;
293 }
294
272295 function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>): Html {
273 const { items, thoughts, placed, marks } = read;
296 const { items, next, cursor, thoughts, placed, marks } = read;
274297 return html`<section
275298 id="live-feed"
276299 class="feed"
277300 aria-label="Feed"
278301 data-live="${FEED_LIVE}"
279 data-live-src="/live/feed"
302 data-live-src="/live/feed${cursor === undefined ? "" : `?cursor=${encodeURIComponent(`${cursor.score}|${cursor.id}`)}`}"
280303 >
281304 ${chipsBar(user, ctx, items)}
282305 ${thoughts.map((t) => thoughtCard(t, user.username, placed.get(t.id)))}
@@ -287,6 +310,7 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>):
287310 note: "When something happens in your apps, it lands here.",
288311 })
289312 : items.map((item) => feedCard(item, marks))}
313 ${caughtUp(items, next, cursor)}
290314 </section>`;
291315 }
292316
@@ -388,7 +412,7 @@ ${outboxJs}
388412 .catch(function () { fail(c, was, 'The filter was not saved because there is no connection. Check it and press the chip again.'); });
389413 }
390414 function refetch(kind, value) {
391 return fetch('/live/feed', { headers: { accept: 'text/html' }, credentials: 'same-origin' })
415 return fetch(feed.getAttribute('data-live-src') || '/live/feed', { headers: { accept: 'text/html' }, credentials: 'same-origin' })
392416 .then(function (r) { if (!r.ok) throw r; return r.text(); })
393417 .then(function (text) {
394418 var holder = document.createElement('template');
test/feed-caught-up.test.ts added +93
@@ -0,0 +1,93 @@
1 /** The line under the home timeline's last post: how far back it reaches, and the way on. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createSession } from "../src/core/sessions.ts";
9 import type { Repo } from "../src/core/repos.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import type { FeedItem } from "../src/feed/rank.ts";
13 import { caughtUp, FEED_FIRST_PAGE, feedJs } from "../src/web/studio/feed.ts";
14 import { promote } from "./helpers/members.ts";
15
16 describe("the timeline's caught-up line", () => {
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let alice: User;
21 let cookie: string;
22 let repo: Repo;
23
24 const emit = (n: number) => {
25 for (let i = 0; i < n; i++)
26 app.bus.append({ kind: "run.finished", repoId: repo.id, actorId: alice.id, payload: { kind: "agent", state: "ok", summary: `work ${i}`, runId: `r${i}` } });
27 };
28 const home = async (path = "/") => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
29
30 beforeAll(async () => {
31 dataDir = await mkdtemp(join(tmpdir(), "forge-caught-up-"));
32 app = createApp({ dataDir, port: 0 });
33 servers = await serve(app);
34 for (const [name, first] of [["root", true], ["alice", false]] as const) {
35 const r = await createUser(app.db, { origin: "seed", trusted: true, username: name, allowReserved: true, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
36 if (!r.ok) throw new Error(r.error);
37 promote(app.db, r.value);
38 if (name === "alice") alice = r.value;
39 }
40 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
41 const made = await createRepo(app, alice, { name: "open", visibility: "public" });
42 if (!made.ok) throw new Error("repo");
43 repo = made.value;
44 });
45
46 afterAll(async () => {
47 servers.stop();
48 app.close();
49 await rm(dataDir, { recursive: true, force: true });
50 });
51
52 test("a short feed says that is everything, with no link", async () => {
53 emit(3);
54 const text = await home();
55 expect(text).toContain("That's everything");
56 expect(text).not.toContain("Older posts");
57 });
58
59 test("a feed with more pages says how far it reaches and links to the next", async () => {
60 emit(FEED_FIRST_PAGE + 5);
61 const text = await home();
62 expect(text).toContain("You're caught up to");
63 expect(text).not.toContain("That's everything");
64 const href = /href="(\/\?cursor=[^"]+)"[^>]*>Older posts/.exec(text);
65 expect(href).not.toBeNull();
66 const older = await home(href![1]!.replaceAll("&amp;", "&"));
67 expect(older).toContain("data-item=");
68 expect(older).toContain("That's everything");
69 expect(older).toContain('<a href="/">Newest</a>');
70 expect(text).not.toContain(">Newest<");
71 });
72
73 test("a page after the first carries its own address, so a chip refetch stays on it", async () => {
74 const href = /href="(\/\?cursor=[^"]+)"[^>]*>Older posts/.exec(await home())![1]!.replaceAll("&amp;", "&");
75 const cursor = href.slice("/?cursor=".length);
76 const older = await home(href);
77 expect(older).toContain(`data-live-src="/live/feed?cursor=${cursor}"`);
78 expect(await home()).toContain('data-live-src="/live/feed"');
79 expect(feedJs).toContain("feed.getAttribute('data-live-src')");
80 });
81
82 test("the time is the oldest shown, not the last in score order", () => {
83 const item = (id: string, at: string) => ({ id, at }) as FeedItem;
84 const html = String(caughtUp([item("a", "2020-01-01T00:00:00.000Z"), item("b", "2024-06-01T00:00:00.000Z")], "n"));
85 expect(html).toContain("2020-01-01T00:00:00.000Z");
86 expect(html).not.toContain("2024-06-01T00:00:00.000Z");
87 });
88
89 test("a cursor not issued here is the first page", async () => {
90 const text = await home("/?cursor=garbage");
91 expect(text).toContain("You're caught up to");
92 });
93 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs