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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5980-the-home-timeline-ends-its-first-page-wi → main 3 commits fork
2 files
changed +125
−8
src/web/studio/feed.ts +32 −8
| @@ -15,12 +15,12 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 15 | 15 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 16 | 16 | import { placementsOf, type Placement } from "../../core/placements.ts"; |
| 17 | 17 | import { recentThoughts, type Thought } from "../../core/thoughts.ts"; |
| 18 | import { readFeed } from "../../feed/feed.ts"; | |
| 18 | import { parseCursor, readFeed } from "../../feed/feed.ts"; | |
| 19 | 19 | import type { FeedItem } from "../../feed/rank.ts"; |
| 20 | 20 | import { html, type Html } from "../html.ts"; |
| 21 | 21 | import { fragment } from "../layout.ts"; |
| 22 | 22 | 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"; | |
| 24 | 24 | import { writableAppsOf } from "./asks.ts"; |
| 25 | 25 | |
| 26 | 26 | /** Events that can change what the feed says. */ |
| @@ -249,11 +249,15 @@ function chipsBar(user: User, ctx: Ctx, items: readonly FeedItem[]): Html { | ||
| 249 | 249 | function readRegion(user: User, ctx: Ctx) { |
| 250 | 250 | // What needs you is pinned above the timeline (`needsYouSection`), so an |
| 251 | 251 | // 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, { | |
| 253 | 255 | limit: FEED_FIRST_PAGE, |
| 254 | 256 | withoutAsks: true, |
| 255 | 257 | phase: (name, ms) => notePhase(ctx.request, name, ms), |
| 256 | }).items; | |
| 258 | ...(cursor === undefined ? {} : { cursor }), | |
| 259 | }); | |
| 260 | const items = page.items; | |
| 257 | 261 | const thoughts = recentThoughts(ctx.app.db, user.id, THOUGHTS_SHOWN, new Date(Date.now() - THOUGHT_AGE_MS).toISOString()); |
| 258 | 262 | const placed = placementsOf(ctx.app.db, thoughts.map((t) => t.id)); |
| 259 | 263 | // Who each poster is, asked once for the page (`core/marks.ts`). |
| @@ -261,7 +265,7 @@ function readRegion(user: User, ctx: Ctx) { | ||
| 261 | 265 | ctx.app.db, |
| 262 | 266 | items.map((item) => item.actor ?? ""), |
| 263 | 267 | ); |
| 264 | return { items, thoughts, placed, marks }; | |
| 268 | return { items, next: page.next_cursor, cursor, thoughts, placed, marks }; | |
| 265 | 269 | } |
| 266 | 270 | |
| 267 | 271 | /** The region: cards, or one line saying there is nothing to do. */ |
| @@ -269,14 +273,33 @@ export function feedRegion(user: User, ctx: Ctx): Html { | ||
| 269 | 273 | return drawRegion(user, ctx, readRegion(user, ctx)); |
| 270 | 274 | } |
| 271 | 275 | |
| 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 | ||
| 272 | 295 | 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; | |
| 274 | 297 | return html`<section |
| 275 | 298 | id="live-feed" |
| 276 | 299 | class="feed" |
| 277 | 300 | aria-label="Feed" |
| 278 | 301 | data-live="${FEED_LIVE}" |
| 279 | data-live-src="/live/feed" | |
| 302 | data-live-src="/live/feed${cursor === undefined ? "" : `?cursor=${encodeURIComponent(`${cursor.score}|${cursor.id}`)}`}" | |
| 280 | 303 | > |
| 281 | 304 | ${chipsBar(user, ctx, items)} |
| 282 | 305 | ${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>): | ||
| 287 | 310 | note: "When something happens in your apps, it lands here.", |
| 288 | 311 | }) |
| 289 | 312 | : items.map((item) => feedCard(item, marks))} |
| 313 | ${caughtUp(items, next, cursor)} | |
| 290 | 314 | </section>`; |
| 291 | 315 | } |
| 292 | 316 | |
| @@ -388,7 +412,7 @@ ${outboxJs} | ||
| 388 | 412 | .catch(function () { fail(c, was, 'The filter was not saved because there is no connection. Check it and press the chip again.'); }); |
| 389 | 413 | } |
| 390 | 414 | 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' }) | |
| 392 | 416 | .then(function (r) { if (!r.ok) throw r; return r.text(); }) |
| 393 | 417 | .then(function (text) { |
| 394 | 418 | 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("&", "&")); | |
| 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("&", "&"); | |
| 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 | }); |
new above
offline