Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/messages/messages.ts
iris bdeec3f744 swarm-ui: key message rows on an arrival id and follow the tail on it
At the 2000-message cap the count stops changing, so the page stopped
following the newest message, and every row's index key shifted on each
arrival, remounting all of them. Each message now gets an id when it
arrives; the rows key on it and the scroll follows the newest id. Rows
are memoised on the message, and the events of one frame join the list
in a single update.
2026-10-18 18:39:11 +02:00

108 lines
3.3 KiB
TypeScript

// 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<T extends SwarmMessage>(
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<T extends SwarmMessage>(
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,
};
}