// The messages page's logic, kept out of the component so `npm test` can run // it: the stream URL, which messages a filter keeps, and how one message // becomes a terminal row. import type { TermRow } from "@hive/shared/term-msg.js"; /** One event of `GET /api/messages/stream`; mirrors `SwarmMessage`. */ export interface SwarmMessage { /** ISO 8601 / RFC 3339 UTC. */ ts: string; from: string; to: string; body: string; body_format?: "markdown" | "diff"; } /** * A message with the id the page gave it when it arrived. The id only grows, * so it names a row for as long as the message is on the page, however many * older ones drop off the front. */ export interface ArrivedMessage extends SwarmMessage { id: number; } /** `batch` numbered from `firstId` on, in order. */ export function numbered( batch: SwarmMessage[], firstId: number, ): ArrivedMessage[] { return batch.map((m, i) => ({ ...m, id: firstId + i })); } /** The history windows the page offers, in minutes; 0 is live only. */ export const WINDOWS: { minutes: number; label: string }[] = [ { minutes: 0, label: "live only" }, { minutes: 15, label: "last 15 min" }, { minutes: 60, label: "last hour" }, { minutes: 360, label: "last 6 hours" }, { minutes: 1440, label: "last 24 hours" }, ]; export const DEFAULT_WINDOW_MINUTES = 60; /** The stream for `sinceMinutes` of history before the live tail. */ export function messagesUrl(sinceMinutes: number): string { return `/api/messages/stream?since_minutes=${sinceMinutes}`; } /** * The messages that name `query` in their sender or recipient, as a * case-insensitive substring; all of them when `query` is blank. */ export function filterMessages( messages: T[], query: string, ): T[] { const q = query.trim().toLowerCase(); if (q === "") return messages; return messages.filter( (m) => m.from.toLowerCase().includes(q) || m.to.toLowerCase().includes(q), ); } /** At most `max` messages, the newest ones, when there are more. */ export function newest( messages: T[], max: number, ): T[] { return messages.length > max ? messages.slice(messages.length - max) : messages; } /** `body`'s first non-blank line, cut to `max` characters with an ellipsis. */ export function snippet(body: string, max: number): string { const line = body .split("\n") .map((l) => l.trim()) .find((l) => l !== "") ?? ""; return line.length > max ? line.slice(0, max - 1) + "…" : line; } /** `ts` as `HH:MM:SS` in the viewer's time zone; `ts` itself when it is no date. */ export function clock(ts: string): string { const d = new Date(ts); if (Number.isNaN(d.getTime())) return ts; const p = (n: number) => String(n).padStart(2, "0"); return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; } /** * The terminal row for `m`: one summary line with the time, the two parties * and the start of the body, and the whole body under it. */ export function messageRow(m: SwarmMessage, key: string): TermRow { const start = snippet(m.body, 80); return { key, ts: m.ts, icon: "💬", level: "info", summary: `${clock(m.ts)} ${m.from} → ${m.to}${start ? ` — ${start}` : ""}`, body: m.body, body_format: m.body_format, fromHistory: false, }; }