Public: anyone can read this; the crew can change it.
The page is a timeline: server-rendered, filtered by link, live as it happens #34
Check · Review · Land · Live done — Landed and live in production.
scout/relay:scout/9-the-page-is-a-timeline-server-rendered-f → main 1 commit fork
6 files
changed +442
−65
src/http.ts +15
| @@ -175,3 +175,18 @@ export function staticFile(name: string, versioned: boolean): Response { | ||
| 175 | 175 | }, |
| 176 | 176 | }); |
| 177 | 177 | } |
| 178 | ||
| 179 | // Docs read straight from the repository, for a page to link to. | |
| 180 | const DOCS = join(import.meta.dir, "..", "docs"); | |
| 181 | export function docFile(name: string): Response { | |
| 182 | const path = join(DOCS, name); | |
| 183 | if (name.includes("/") || name.includes("..") || !existsSync(path)) | |
| 184 | return text("not found", 404); | |
| 185 | return new Response(Bun.file(path), { | |
| 186 | headers: { | |
| 187 | "content-type": "text/markdown; charset=utf-8", | |
| 188 | "cache-control": "no-cache", | |
| 189 | ...SECURITY, | |
| 190 | }, | |
| 191 | }); | |
| 192 | } |
src/page.ts +138 −17
| @@ -5,7 +5,15 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { Event } from "./actions.ts"; |
| 8 | import { commit, description, env, ssh, title, where } from "./env.ts"; | |
| 8 | import { | |
| 9 | commit, | |
| 10 | description, | |
| 11 | env, | |
| 12 | publicOrigin, | |
| 13 | ssh, | |
| 14 | title, | |
| 15 | where, | |
| 16 | } from "./env.ts"; | |
| 9 | 17 | import { asset, escape } from "./http.ts"; |
| 10 | 18 | import type { Who } from "./who.ts"; |
| 11 | 19 | |
| @@ -47,27 +55,140 @@ export function layout(body: string, who: Who): string { | ||
| 47 | 55 | `; |
| 48 | 56 | } |
| 49 | 57 | |
| 50 | export function home(input: { who: Who; events: readonly Event[] }): string { | |
| 51 | const { who, events } = input; | |
| 52 | const list = | |
| 53 | events.length === 0 | |
| 54 | ? `<p class="empty">No events yet. Events posted here stay in ${escape(where)} data.</p>` | |
| 55 | : `<ul class="list">${events.map(item).join("")}</ul>`; | |
| 58 | export function home(input: { | |
| 59 | who: Who; | |
| 60 | events: readonly Event[]; | |
| 61 | source: string | null; | |
| 62 | kind: string | null; | |
| 63 | error?: string; | |
| 64 | }): string { | |
| 65 | const { who, events, source, kind, error } = input; | |
| 66 | const filtered = source !== null || kind !== null; | |
| 67 | const body = | |
| 68 | events.length === 0 ? empty(filtered) : days(events, source, kind); | |
| 56 | 69 | return layout( |
| 57 | `<section id="events" data-live="event.posted">${list}</section>`, | |
| 70 | `<section id="events" data-live="event.posted"> | |
| 71 | ${toolbar(source, kind, error)} | |
| 72 | ${body} | |
| 73 | </section>`, | |
| 58 | 74 | who, |
| 59 | 75 | ); |
| 60 | 76 | } |
| 61 | 77 | |
| 62 | const item = (event: Event): string => `<li class="event"> | |
| 63 | <div> | |
| 64 | <p><b>${escape(event.subject)}</b> — ${escape(event.summary)}</p> | |
| 65 | <small class="muted">${escape(event.source)} · ${escape(event.kind)} · <time datetime="${escape(event.occurred_at)}">${ago(event.occurred_at)}</time>${ | |
| 66 | event.link === null | |
| 67 | ? "" | |
| 68 | : ` · <a href="${escape(event.link)}" rel="noopener noreferrer">link</a>` | |
| 69 | }</small> | |
| 70 | </div> | |
| 78 | /** A link that carries the filter forward: dropping a field clears it. */ | |
| 79 | const filterHref = (source: string | null, kind: string | null): string => { | |
| 80 | const params = new URLSearchParams(); | |
| 81 | if (source !== null) params.set("source", source); | |
| 82 | if (kind !== null) params.set("kind", kind); | |
| 83 | const qs = params.toString(); | |
| 84 | return qs === "" ? "/" : `/?${qs}`; | |
| 85 | }; | |
| 86 | ||
| 87 | const toggle = (current: string | null, value: string): string | null => | |
| 88 | current === value ? null : value; | |
| 89 | ||
| 90 | /** The action's message when a filter is invalid, and the active filter as clearable chips. */ | |
| 91 | const toolbar = ( | |
| 92 | source: string | null, | |
| 93 | kind: string | null, | |
| 94 | error: string | undefined, | |
| 95 | ): string => { | |
| 96 | const parts: string[] = []; | |
| 97 | if (error !== undefined) | |
| 98 | parts.push(`<p class="notice" role="alert">${escape(error)}</p>`); | |
| 99 | const chips: string[] = []; | |
| 100 | if (source !== null) | |
| 101 | chips.push( | |
| 102 | `<a class="badge badge-active" href="${escape(filterHref(null, kind))}">${escape(source)} <span aria-hidden="true">✕</span></a>`, | |
| 103 | ); | |
| 104 | if (kind !== null) | |
| 105 | chips.push( | |
| 106 | `<a class="kind kind-active" href="${escape(filterHref(source, null))}">${escape(kind)} <span aria-hidden="true">✕</span></a>`, | |
| 107 | ); | |
| 108 | if (chips.length > 0) | |
| 109 | parts.push( | |
| 110 | `<p class="filters">Filtered by ${chips.join(" ")}${ | |
| 111 | source !== null && kind !== null | |
| 112 | ? ` <a class="clear" href="/">clear all</a>` | |
| 113 | : "" | |
| 114 | }</p>`, | |
| 115 | ); | |
| 116 | return parts.join(""); | |
| 117 | }; | |
| 118 | ||
| 119 | const empty = (filtered: boolean): string => | |
| 120 | filtered | |
| 121 | ? `<p class="empty">No events match this filter. <a href="/">Clear it</a> to see everything.</p>` | |
| 122 | : `<div class="empty"> | |
| 123 | <p>No events yet. Post one from another app:</p> | |
| 124 | <pre class="mono">curl -X POST ${escape(publicOrigin)}/api/events \\ | |
| 125 | -H "content-type: application/json" \\ | |
| 126 | -H "x-relay-app: $RELAY_APP" \\ | |
| 127 | -H "x-relay-timestamp: $TIMESTAMP" \\ | |
| 128 | -H "x-relay-nonce: $NONCE" \\ | |
| 129 | -H "x-relay-signature: $SIGNATURE" \\ | |
| 130 | --data-binary '{"kind":"order.placed","subject":"Order #42","summary":"A new order came in."}'</pre> | |
| 131 | <p>See <a href="/docs/adopting.md">docs/adopting.md</a> for how to get a key and sign a request.</p> | |
| 132 | </div>`; | |
| 133 | ||
| 134 | /** Newest first, grouped under a heading for the day it happened. */ | |
| 135 | const days = ( | |
| 136 | events: readonly Event[], | |
| 137 | source: string | null, | |
| 138 | kind: string | null, | |
| 139 | ): string => { | |
| 140 | const out: string[] = []; | |
| 141 | let current: string | undefined; | |
| 142 | let items: Event[] = []; | |
| 143 | const flush = (): void => { | |
| 144 | if (current === undefined) return; | |
| 145 | out.push( | |
| 146 | `<h2 class="day">${escape(dayLabel(current))}</h2><ul class="list" data-day="${escape(current)}">${items.map((e) => item(e, source, kind)).join("")}</ul>`, | |
| 147 | ); | |
| 148 | }; | |
| 149 | for (const event of events) { | |
| 150 | const key = dayKey(event.occurred_at); | |
| 151 | if (key !== current) { | |
| 152 | flush(); | |
| 153 | current = key; | |
| 154 | items = []; | |
| 155 | } | |
| 156 | items.push(event); | |
| 157 | } | |
| 158 | flush(); | |
| 159 | return out.join(""); | |
| 160 | }; | |
| 161 | ||
| 162 | const dayKey = (iso: string): string => iso.slice(0, 10); | |
| 163 | ||
| 164 | const dayLabel = (key: string): string => { | |
| 165 | const today = new Date().toISOString().slice(0, 10); | |
| 166 | const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); | |
| 167 | if (key === today) return "Today"; | |
| 168 | if (key === yesterday) return "Yesterday"; | |
| 169 | return new Date(`${key}T00:00:00Z`).toLocaleDateString("en", { | |
| 170 | month: "long", | |
| 171 | day: "numeric", | |
| 172 | year: "numeric", | |
| 173 | timeZone: "UTC", | |
| 174 | }); | |
| 175 | }; | |
| 176 | ||
| 177 | const item = ( | |
| 178 | event: Event, | |
| 179 | source: string | null, | |
| 180 | kind: string | null, | |
| 181 | ): string => `<li class="event" data-id="${event.id}"> | |
| 182 | <p class="body"><b>${escape(event.subject)}</b> — ${escape(event.summary)}${ | |
| 183 | event.link === null | |
| 184 | ? "" | |
| 185 | : ` · <a href="${escape(event.link)}" rel="noopener noreferrer">link</a>` | |
| 186 | }</p> | |
| 187 | <p class="meta"> | |
| 188 | <a class="badge${source === event.source ? " badge-active" : ""}" href="${escape(filterHref(toggle(source, event.source), kind))}">${escape(event.source)}</a> | |
| 189 | <a class="kind${kind === event.kind ? " kind-active" : ""}" href="${escape(filterHref(source, toggle(kind, event.kind)))}">${escape(event.kind)}</a> | |
| 190 | <time datetime="${escape(event.occurred_at)}">${ago(event.occurred_at)}</time> | |
| 191 | </p> | |
| 71 | 192 | </li>`; |
| 72 | 193 | |
| 73 | 194 | const ago = (iso: string): string => { |
src/routes.ts +19 −2
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | type Result, |
| 14 | 14 | } from "./actions.ts"; |
| 15 | 15 | import { |
| 16 | docFile, | |
| 16 | 17 | fields, |
| 17 | 18 | html, |
| 18 | 19 | json, |
| @@ -61,8 +62,21 @@ export const routes: readonly Route[] = [ | ||
| 61 | 62 | method: "GET", |
| 62 | 63 | path: "/", |
| 63 | 64 | handle: (c) => { |
| 64 | const listed = listEvents(c.db, c.who); | |
| 65 | return html(home({ who: c.who, events: listed.ok ? listed.value : [] })); | |
| 65 | const source = c.url.searchParams.get("source"); | |
| 66 | const kind = c.url.searchParams.get("kind"); | |
| 67 | const listed = listEvents(c.db, c.who, { | |
| 68 | source: source ?? undefined, | |
| 69 | kind: kind ?? undefined, | |
| 70 | }); | |
| 71 | return html( | |
| 72 | home({ | |
| 73 | who: c.who, | |
| 74 | events: listed.ok ? listed.value : [], | |
| 75 | source, | |
| 76 | kind, | |
| 77 | error: listed.ok ? undefined : listed.message, | |
| 78 | }), | |
| 79 | ); | |
| 66 | 80 | }, |
| 67 | 81 | }, |
| 68 | 82 | |
| @@ -131,6 +145,9 @@ export const routes: readonly Route[] = [ | ||
| 131 | 145 | path: "/static/:file", |
| 132 | 146 | handle: (c) => staticFile(c.params.file ?? "", c.url.searchParams.has("v")), |
| 133 | 147 | }, |
| 148 | ||
| 149 | // Files under ./docs, read straight from the repository. | |
| 150 | { method: "GET", path: "/docs/:file", handle: (c) => docFile(c.params.file ?? "") }, | |
| 134 | 151 | ]; |
| 135 | 152 | |
| 136 | 153 | /** The route for a method and path, with what its `:names` matched. */ |
src/server.test.ts +59
| @@ -95,6 +95,65 @@ test("the JSON API: only an admin (or a verified app) posts, as a named source", | ||
| 95 | 95 | expect(invalid.status).toBe(422); |
| 96 | 96 | }); |
| 97 | 97 | |
| 98 | test("the timeline is server-rendered, escaped, and its filter round-trips", async () => { | |
| 99 | await api( | |
| 100 | "/api/events", | |
| 101 | { | |
| 102 | method: "POST", | |
| 103 | ...jsonBody({ | |
| 104 | kind: "order.placed", | |
| 105 | subject: "Order #9001", | |
| 106 | summary: "<script>alert(1)</script>", | |
| 107 | source: "timeline", | |
| 108 | }), | |
| 109 | }, | |
| 110 | root, | |
| 111 | ); | |
| 112 | const page = await (await fetch(base + "/")).text(); | |
| 113 | // The newest event is in the first paint — no JavaScript involved, this | |
| 114 | // is a plain fetch of the HTML. | |
| 115 | expect(page).toContain("Order #9001"); | |
| 116 | // A poster-controlled field never becomes markup. | |
| 117 | expect(page).not.toContain("<script>alert(1)</script>"); | |
| 118 | expect(page).toContain("<script>alert(1)</script>"); | |
| 119 | // Nothing inline was added to make any of this work. | |
| 120 | expect(/<script(?![^>]*\bsrc=)/.test(page)).toBe(false); | |
| 121 | ||
| 122 | const filtered = await (await fetch(base + "/?source=timeline")).text(); | |
| 123 | expect(filtered).toContain("Order #9001"); | |
| 124 | // The filter carries forward into the other chips, so it survives a click. | |
| 125 | expect(filtered).toContain('href="/?source=timeline&kind=order.placed"'); | |
| 126 | // The active chip clears the filter it names, back to the same address. | |
| 127 | expect(filtered).toContain('class="badge badge-active" href="/"'); | |
| 128 | const reloaded = await (await fetch(base + "/?source=timeline")).text(); | |
| 129 | expect(reloaded).toBe(filtered); | |
| 130 | ||
| 131 | const invalid = await fetch(base + "/?source=Not%20Valid!"); | |
| 132 | expect(invalid.status).toBe(200); | |
| 133 | expect(await invalid.text()).toContain("source is invalid"); | |
| 134 | ||
| 135 | expect( | |
| 136 | (await fetch(base + "/")).headers.get("content-security-policy"), | |
| 137 | ).toContain("default-src 'self'"); | |
| 138 | }); | |
| 139 | ||
| 140 | test("an empty timeline explains how to post, with no dead ends", async () => { | |
| 141 | const fresh = serve({ | |
| 142 | port: 0, | |
| 143 | dataDir: mkdtempSync(join(tmpdir(), "app-")), | |
| 144 | identityKey: KEY, | |
| 145 | }); | |
| 146 | try { | |
| 147 | const freshBase = `http://${fresh.hostname}:${fresh.port}`; | |
| 148 | const page = await (await fetch(freshBase + "/")).text(); | |
| 149 | expect(page).toContain("curl -X POST"); | |
| 150 | expect(page).toContain("/docs/adopting.md"); | |
| 151 | expect((await fetch(freshBase + "/docs/adopting.md")).status).toBe(200); | |
| 152 | } finally { | |
| 153 | fresh.stop(true); | |
| 154 | } | |
| 155 | }); | |
| 156 | ||
| 98 | 157 | test("kind and source filters work over the API", async () => { |
| 99 | 158 | await api( |
| 100 | 159 | "/api/events", |
static/app.css +88 −6
| @@ -109,16 +109,25 @@ code, | ||
| 109 | 109 | align-items: center; |
| 110 | 110 | gap: var(); |
| 111 | 111 | } |
| 112 | .day { | |
| 113 | margin: var() 0 var(); | |
| 114 | font-size: var(); | |
| 115 | font-weight: 700; | |
| 116 | color: var(); | |
| 117 | text-transform: uppercase; | |
| 118 | letter-spacing: 0.04em; | |
| 119 | } | |
| 120 | .day:first-child { | |
| 121 | margin-top: 0; | |
| 122 | } | |
| 112 | 123 | .list { |
| 113 | 124 | list-style: none; |
| 114 | 125 | margin: 0; |
| 115 | 126 | padding: 0; |
| 116 | 127 | } |
| 117 | 128 | .event { |
| 118 | display: flex; | |
| 119 | justify-content: space-between; | |
| 120 | align-items: flex-start; | |
| 121 | gap: var(); | |
| 129 | display: grid; | |
| 130 | gap: var(); | |
| 122 | 131 | padding: var() 0; |
| 123 | 132 | border-top: 1px solid var(); |
| 124 | 133 | } |
| @@ -126,18 +135,59 @@ code, | ||
| 126 | 135 | border-top: 0; |
| 127 | 136 | } |
| 128 | 137 | .event p { |
| 138 | margin: 0; | |
| 129 | 139 | overflow-wrap: anywhere; |
| 130 | 140 | } |
| 131 | .event small { | |
| 141 | .event .meta { | |
| 142 | display: flex; | |
| 143 | flex-wrap: wrap; | |
| 144 | align-items: center; | |
| 145 | gap: var() var(); | |
| 132 | 146 | font-size: var(); |
| 147 | color: var(); | |
| 148 | } | |
| 149 | .event time { | |
| 150 | white-space: nowrap; | |
| 133 | 151 | } |
| 134 | 152 | .empty { |
| 135 | padding: var() var(); | |
| 153 | padding: var() var(); | |
| 136 | 154 | text-align: center; |
| 137 | 155 | color: var(); |
| 138 | 156 | border: 1px dashed var(); |
| 139 | 157 | border-radius: var(); |
| 140 | 158 | } |
| 159 | .empty pre { | |
| 160 | margin: var() 0; | |
| 161 | padding: var(); | |
| 162 | overflow-x: auto; | |
| 163 | text-align: left; | |
| 164 | background: var(); | |
| 165 | border: 1px solid var(); | |
| 166 | border-radius: var(); | |
| 167 | } | |
| 168 | .notice { | |
| 169 | padding: var(); | |
| 170 | color: var(); | |
| 171 | background: var(); | |
| 172 | border: 1px solid currentColor; | |
| 173 | border-radius: var(); | |
| 174 | margin-bottom: var(); | |
| 175 | } | |
| 176 | .filters { | |
| 177 | display: flex; | |
| 178 | flex-wrap: wrap; | |
| 179 | align-items: center; | |
| 180 | gap: var(); | |
| 181 | margin-bottom: var(); | |
| 182 | color: var(); | |
| 183 | } | |
| 184 | /* Standalone, away from a dense row: full tap size. */ | |
| 185 | .filters .badge, | |
| 186 | .filters .kind, | |
| 187 | .filters .clear { | |
| 188 | min-height: var(); | |
| 189 | padding: 0 var(); | |
| 190 | } | |
| 141 | 191 | footer { |
| 142 | 192 | margin-top: var(); |
| 143 | 193 | font-size: var(); |
| @@ -190,3 +240,35 @@ footer code { | ||
| 190 | 240 | color: var(); |
| 191 | 241 | border-color: currentColor; |
| 192 | 242 | } |
| 243 | ||
| 244 | /* Filter chips: a source is a badge, a kind is plainer text — either | |
| 245 | clicked sets that filter, and clicking the one already active clears it. */ | |
| 246 | .badge, | |
| 247 | .kind, | |
| 248 | .clear { | |
| 249 | display: inline-flex; | |
| 250 | align-items: center; | |
| 251 | min-height: 2rem; | |
| 252 | padding: 0.1rem 0.6rem; | |
| 253 | font-size: var(); | |
| 254 | text-decoration: none; | |
| 255 | border-radius: 999px; | |
| 256 | } | |
| 257 | .badge { | |
| 258 | font-weight: 600; | |
| 259 | color: var(); | |
| 260 | border: 1px solid var(); | |
| 261 | } | |
| 262 | .kind, | |
| 263 | .clear { | |
| 264 | color: var(); | |
| 265 | text-decoration: underline; | |
| 266 | text-decoration-thickness: 1px; | |
| 267 | } | |
| 268 | .badge-active, | |
| 269 | .kind-active { | |
| 270 | color: var(); | |
| 271 | background: var(); | |
| 272 | border-color: var(); | |
| 273 | text-decoration: none; | |
| 274 | } |
static/live.js +123 −40
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | // The page stays current without a reload. /events is a stream of what |
| 2 | // changed; each event becomes `live` and `live:<type>` on `document`, and | |
| 3 | // any element with data-live="<types>" is refetched and swapped when one of | |
| 4 | // its types arrives — server-rendered, so the page never renders twice. | |
| 5 | // Without this file the page is the same page, one reload behind. | |
| 2 | // changed; each event becomes `live` and `live:<type>` on `document`. The | |
| 3 | // timeline (`data-live="event.posted"`) prepends a row built straight from | |
| 4 | // the pushed data with textContent, never innerHTML — that data came from | |
| 5 | // another app on the forge, and is never trusted as markup. | |
| 6 | 6 | |
| 7 | 7 | let since = ""; |
| 8 | 8 | let tries = 0; |
| @@ -11,7 +11,6 @@ let source; | ||
| 11 | 11 | function connect() { |
| 12 | 12 | source = new EventSource(since ? `/events?since=${since}` : "/events"); |
| 13 | 13 | source.onopen = () => { |
| 14 | if (tries > 0) refresh(); // Reconnected: whatever was missed, catch up. | |
| 15 | 14 | tries = 0; |
| 16 | 15 | }; |
| 17 | 16 | source.onmessage = (message) => { |
| @@ -21,7 +20,8 @@ function connect() { | ||
| 21 | 20 | document.dispatchEvent(new CustomEvent(`live:${type}`, { detail: data })); |
| 22 | 21 | }; |
| 23 | 22 | // The browser retries a dropped connection itself; a refused one it gives |
| 24 | // up on, and then we try again, later each time. | |
| 23 | // up on, and then we try again, later each time. Reconnecting with | |
| 24 | // ?since= replays whatever was missed, as ordinary messages. | |
| 25 | 25 | source.onerror = () => { |
| 26 | 26 | if (source.readyState !== EventSource.CLOSED) return; |
| 27 | 27 | source.close(); |
| @@ -29,40 +29,123 @@ function connect() { | ||
| 29 | 29 | }; |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | let refreshing = false; | |
| 33 | let again = false; | |
| 34 | async function refresh(types) { | |
| 35 | const regions = [...document.querySelectorAll("[data-live]")].filter( | |
| 36 | (el) => | |
| 37 | types === undefined || | |
| 38 | el.dataset.live.split(" ").some((t) => types.includes(t)), | |
| 39 | ); | |
| 40 | if (regions.length === 0) return; | |
| 41 | if (refreshing) { | |
| 42 | again = true; | |
| 43 | return; | |
| 44 | } | |
| 45 | refreshing = true; | |
| 46 | try { | |
| 47 | const response = await fetch(location.href, { | |
| 48 | headers: { accept: "text/html" }, | |
| 49 | }); | |
| 50 | const fresh = new DOMParser().parseFromString( | |
| 51 | await response.text(), | |
| 52 | "text/html", | |
| 53 | ); | |
| 54 | for (const el of regions) { | |
| 55 | const next = fresh.getElementById(el.id); | |
| 56 | if (next) el.replaceWith(next); | |
| 57 | } | |
| 58 | } finally { | |
| 59 | refreshing = false; | |
| 60 | if (again) { | |
| 61 | again = false; | |
| 62 | refresh(); | |
| 63 | } | |
| 64 | } | |
| 32 | // Mirrors src/actions.ts: kind matches exactly, or as a dotted prefix. | |
| 33 | function matchesFilter(event) { | |
| 34 | const params = new URLSearchParams(location.search); | |
| 35 | const source = params.get("source"); | |
| 36 | const kind = params.get("kind"); | |
| 37 | if (source !== null && event.source !== source) return false; | |
| 38 | if (kind !== null && event.kind !== kind && !event.kind.startsWith(kind + ".")) | |
| 39 | return false; | |
| 40 | return true; | |
| 65 | 41 | } |
| 66 | 42 | |
| 67 | document.addEventListener("live", (event) => refresh([event.detail.type])); | |
| 43 | // Mirrors src/page.ts: relative time for a row just built client-side. | |
| 44 | function ago(iso) { | |
| 45 | const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000); | |
| 46 | if (s < 60) return "just now"; | |
| 47 | if (s < 3600) return `${Math.floor(s / 60)}m ago`; | |
| 48 | if (s < 86400) return `${Math.floor(s / 3600)}h ago`; | |
| 49 | return new Date(iso).toLocaleDateString("en", { month: "short", day: "numeric" }); | |
| 50 | } | |
| 51 | ||
| 52 | function dayKey(iso) { | |
| 53 | return iso.slice(0, 10); | |
| 54 | } | |
| 55 | ||
| 56 | function dayLabel(key) { | |
| 57 | const today = new Date().toISOString().slice(0, 10); | |
| 58 | const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); | |
| 59 | if (key === today) return "Today"; | |
| 60 | if (key === yesterday) return "Yesterday"; | |
| 61 | return new Date(`${key}T00:00:00Z`).toLocaleDateString("en", { | |
| 62 | month: "long", | |
| 63 | day: "numeric", | |
| 64 | year: "numeric", | |
| 65 | timeZone: "UTC", | |
| 66 | }); | |
| 67 | } | |
| 68 | ||
| 69 | function link(href, text) { | |
| 70 | const a = document.createElement("a"); | |
| 71 | a.href = href; | |
| 72 | a.textContent = text; | |
| 73 | return a; | |
| 74 | } | |
| 75 | ||
| 76 | function buildRow(event) { | |
| 77 | const li = document.createElement("li"); | |
| 78 | li.className = "event"; | |
| 79 | li.dataset.id = String(event.id); | |
| 80 | ||
| 81 | const body = document.createElement("p"); | |
| 82 | body.className = "body"; | |
| 83 | const b = document.createElement("b"); | |
| 84 | b.textContent = event.subject; | |
| 85 | body.append(b, ` — ${event.summary}`); | |
| 86 | if (event.link !== null) { | |
| 87 | try { | |
| 88 | const url = new URL(event.link); | |
| 89 | if (url.protocol === "http:" || url.protocol === "https:") { | |
| 90 | body.append(" · "); | |
| 91 | const a = link(event.link, "link"); | |
| 92 | a.rel = "noopener noreferrer"; | |
| 93 | body.append(a); | |
| 94 | } | |
| 95 | } catch { | |
| 96 | // Not a URL relay would have accepted; leave it off the row. | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | const meta = document.createElement("p"); | |
| 101 | meta.className = "meta"; | |
| 102 | const params = new URLSearchParams(location.search); | |
| 103 | const sourceActive = params.get("source") === event.source; | |
| 104 | const sourceParams = new URLSearchParams(params); | |
| 105 | if (sourceActive) sourceParams.delete("source"); | |
| 106 | else sourceParams.set("source", event.source); | |
| 107 | const badge = link(sourceParams.size ? `/?${sourceParams}` : "/", event.source); | |
| 108 | badge.className = sourceActive ? "badge badge-active" : "badge"; | |
| 109 | const kindActive = params.get("kind") === event.kind; | |
| 110 | const kindParams = new URLSearchParams(params); | |
| 111 | if (kindActive) kindParams.delete("kind"); | |
| 112 | else kindParams.set("kind", event.kind); | |
| 113 | const kind = link(kindParams.size ? `/?${kindParams}` : "/", event.kind); | |
| 114 | kind.className = kindActive ? "kind kind-active" : "kind"; | |
| 115 | const time = document.createElement("time"); | |
| 116 | time.dateTime = event.occurred_at; | |
| 117 | time.textContent = ago(event.occurred_at); | |
| 118 | meta.append(badge, " ", kind, " ", time); | |
| 119 | ||
| 120 | li.append(body, meta); | |
| 121 | return li; | |
| 122 | } | |
| 123 | ||
| 124 | function addEvent(event) { | |
| 125 | const section = document.querySelector('[data-live~="event.posted"]'); | |
| 126 | if (section === null || !matchesFilter(event)) return; | |
| 127 | if (section.querySelector(`[data-id="${CSS.escape(String(event.id))}"]`)) | |
| 128 | return; // Already shown — a reconnect replayed it, or two tabs raced. | |
| 129 | ||
| 130 | const key = dayKey(event.occurred_at); | |
| 131 | let list = section.querySelector(`.list[data-day="${CSS.escape(key)}"]`); | |
| 132 | if (list === null) { | |
| 133 | const heading = document.createElement("h2"); | |
| 134 | heading.className = "day"; | |
| 135 | heading.textContent = dayLabel(key); | |
| 136 | list = document.createElement("ul"); | |
| 137 | list.className = "list"; | |
| 138 | list.dataset.day = key; | |
| 139 | const firstDay = section.querySelector(".day"); | |
| 140 | section.querySelector(".empty")?.remove(); | |
| 141 | if (firstDay === null) section.append(heading, list); | |
| 142 | else { | |
| 143 | section.insertBefore(heading, firstDay); | |
| 144 | section.insertBefore(list, firstDay); | |
| 145 | } | |
| 146 | } | |
| 147 | list.prepend(buildRow(event)); | |
| 148 | } | |
| 149 | ||
| 150 | document.addEventListener("live:event.posted", (event) => addEvent(event.detail)); | |
| 68 | 151 | connect(); |