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.
This commit is contained in:
parent
447498fef7
commit
bdeec3f744
4 changed files with 114 additions and 19 deletions
|
|
@ -8,6 +8,7 @@
|
||||||
// no turn; the page says so under the list. Each message is a row of the same
|
// no turn; the page says so under the list. Each message is a row of the same
|
||||||
// terminal list an agent's own terminal uses, one summary line with the time,
|
// terminal list an agent's own terminal uses, one summary line with the time,
|
||||||
// the two parties and the start of the body, and the whole body under it.
|
// the two parties and the start of the body, and the whole body under it.
|
||||||
|
import { memo } from "preact/compat";
|
||||||
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||||
import { Row } from "@hive/shared/term-row.js";
|
import { Row } from "@hive/shared/term-row.js";
|
||||||
|
|
@ -15,6 +16,7 @@ import { Panel } from "../../ui/panel/Panel.js";
|
||||||
import { SelectField } from "../../ui/select-field/SelectField.js";
|
import { SelectField } from "../../ui/select-field/SelectField.js";
|
||||||
import { TextField } from "../../ui/text-field/TextField.js";
|
import { TextField } from "../../ui/text-field/TextField.js";
|
||||||
import {
|
import {
|
||||||
|
type ArrivedMessage,
|
||||||
DEFAULT_WINDOW_MINUTES,
|
DEFAULT_WINDOW_MINUTES,
|
||||||
filterMessages,
|
filterMessages,
|
||||||
messageRow,
|
messageRow,
|
||||||
|
|
@ -39,6 +41,13 @@ const CONNECTION_TONE: Record<ConnectionState, BadgeTone> = {
|
||||||
closed: "negative",
|
closed: "negative",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// One message's row. Memoised on the message, which keeps its identity while it
|
||||||
|
// is on the page, so a new arrival or a keystroke in the filter re-renders only
|
||||||
|
// the rows that changed, not every body's markdown.
|
||||||
|
const MessageRow = memo(function MessageRow({ m }: { m: ArrivedMessage }) {
|
||||||
|
return <Row row={messageRow(m, String(m.id))} />;
|
||||||
|
});
|
||||||
|
|
||||||
export function MessagesPage() {
|
export function MessagesPage() {
|
||||||
const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES);
|
const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
@ -57,11 +66,13 @@ export function MessagesPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// After new rows are committed, follow the tail unless the reader has
|
// After new rows are committed, follow the tail unless the reader has
|
||||||
// scrolled up.
|
// scrolled up. The newest id changes with every arrival, which the count
|
||||||
|
// does not once the list is full and each arrival pushes one off the front.
|
||||||
|
const newestId = shown.length > 0 ? shown[shown.length - 1].id : -1;
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = logRef.current;
|
const el = logRef.current;
|
||||||
if (el && stickToBottom) el.scrollTop = el.scrollHeight - el.clientHeight;
|
if (el && stickToBottom) el.scrollTop = el.scrollHeight - el.clientHeight;
|
||||||
}, [shown.length, stickToBottom]);
|
}, [newestId, shown.length, stickToBottom]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
|
|
@ -111,12 +122,7 @@ export function MessagesPage() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
shown.map((m, i) => (
|
shown.map((m) => <MessageRow key={m.id} m={m} />)
|
||||||
<Row
|
|
||||||
key={`${m.ts}|${m.from}|${m.to}|${i}`}
|
|
||||||
row={messageRow(m, "")}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,23 @@ export interface SwarmMessage {
|
||||||
body_format?: "markdown" | "diff";
|
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. */
|
/** The history windows the page offers, in minutes; 0 is live only. */
|
||||||
export const WINDOWS: { minutes: number; label: string }[] = [
|
export const WINDOWS: { minutes: number; label: string }[] = [
|
||||||
{ minutes: 0, label: "live only" },
|
{ minutes: 0, label: "live only" },
|
||||||
|
|
@ -33,10 +50,10 @@ export function messagesUrl(sinceMinutes: number): string {
|
||||||
* The messages that name `query` in their sender or recipient, as a
|
* The messages that name `query` in their sender or recipient, as a
|
||||||
* case-insensitive substring; all of them when `query` is blank.
|
* case-insensitive substring; all of them when `query` is blank.
|
||||||
*/
|
*/
|
||||||
export function filterMessages(
|
export function filterMessages<T extends SwarmMessage>(
|
||||||
messages: SwarmMessage[],
|
messages: T[],
|
||||||
query: string,
|
query: string,
|
||||||
): SwarmMessage[] {
|
): T[] {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (q === "") return messages;
|
if (q === "") return messages;
|
||||||
return messages.filter(
|
return messages.filter(
|
||||||
|
|
@ -45,7 +62,10 @@ export function filterMessages(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** At most `max` messages, the newest ones, when there are more. */
|
/** At most `max` messages, the newest ones, when there are more. */
|
||||||
export function newest(messages: SwarmMessage[], max: number): SwarmMessage[] {
|
export function newest<T extends SwarmMessage>(
|
||||||
|
messages: T[],
|
||||||
|
max: number,
|
||||||
|
): T[] {
|
||||||
return messages.length > max
|
return messages.length > max
|
||||||
? messages.slice(messages.length - max)
|
? messages.slice(messages.length - max)
|
||||||
: messages;
|
: messages;
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,13 @@
|
||||||
// start; the list therefore empties each time the connection opens, and the
|
// start; the list therefore empties each time the connection opens, and the
|
||||||
// replay refills it. Not `useSwarmTermStream`: that one keeps its rows across
|
// replay refills it. Not `useSwarmTermStream`: that one keeps its rows across
|
||||||
// a reconnect, which here would list every message twice.
|
// a reconnect, which here would list every message twice.
|
||||||
import { useEffect, useState } from "preact/hooks";
|
//
|
||||||
import type { SwarmMessage } from "./messages.js";
|
// Each message gets an id as it arrives, and the events that arrive within one
|
||||||
import { messagesUrl, newest } from "./messages.js";
|
// frame join the list in a single update, so a long replay is one render per
|
||||||
|
// frame instead of one per message.
|
||||||
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import type { ArrivedMessage, SwarmMessage } from "./messages.js";
|
||||||
|
import { messagesUrl, newest, numbered } from "./messages.js";
|
||||||
|
|
||||||
export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
|
export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
|
||||||
|
|
||||||
|
|
@ -13,17 +17,37 @@ export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
|
||||||
export const MAX_MESSAGES = 2000;
|
export const MAX_MESSAGES = 2000;
|
||||||
|
|
||||||
export function useMessageStream(sinceMinutes: number): {
|
export function useMessageStream(sinceMinutes: number): {
|
||||||
messages: SwarmMessage[];
|
messages: ArrivedMessage[];
|
||||||
connection: ConnectionState;
|
connection: ConnectionState;
|
||||||
} {
|
} {
|
||||||
const [messages, setMessages] = useState<SwarmMessage[]>([]);
|
const [messages, setMessages] = useState<ArrivedMessage[]>([]);
|
||||||
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
||||||
|
// The next id to hand out; it never goes back, so an id is not reused after
|
||||||
|
// a reconnect either.
|
||||||
|
const nextId = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setConnection("connecting");
|
setConnection("connecting");
|
||||||
const es = new EventSource(messagesUrl(sinceMinutes));
|
const es = new EventSource(messagesUrl(sinceMinutes));
|
||||||
|
let pending: SwarmMessage[] = [];
|
||||||
|
let frame: number | null = null;
|
||||||
|
|
||||||
|
function drop() {
|
||||||
|
pending = [];
|
||||||
|
if (frame !== null) cancelAnimationFrame(frame);
|
||||||
|
frame = null;
|
||||||
|
}
|
||||||
|
function flush() {
|
||||||
|
frame = null;
|
||||||
|
const batch = numbered(pending, nextId.current);
|
||||||
|
pending = [];
|
||||||
|
nextId.current += batch.length;
|
||||||
|
setMessages((prev) => newest([...prev, ...batch], MAX_MESSAGES));
|
||||||
|
}
|
||||||
|
|
||||||
es.onopen = () => {
|
es.onopen = () => {
|
||||||
|
drop();
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setConnection("open");
|
setConnection("open");
|
||||||
};
|
};
|
||||||
|
|
@ -34,14 +58,18 @@ export function useMessageStream(sinceMinutes: number): {
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMessages((prev) => newest([...prev, m], MAX_MESSAGES));
|
pending.push(m);
|
||||||
|
if (frame === null) frame = requestAnimationFrame(flush);
|
||||||
};
|
};
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
setConnection(
|
setConnection(
|
||||||
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
|
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
return () => es.close();
|
return () => {
|
||||||
|
drop();
|
||||||
|
es.close();
|
||||||
|
};
|
||||||
}, [sinceMinutes]);
|
}, [sinceMinutes]);
|
||||||
|
|
||||||
return { messages, connection };
|
return { messages, connection };
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
messageRow,
|
messageRow,
|
||||||
messagesUrl,
|
messagesUrl,
|
||||||
newest,
|
newest,
|
||||||
|
numbered,
|
||||||
snippet,
|
snippet,
|
||||||
} from "../src/pages/messages/messages.ts";
|
} from "../src/pages/messages/messages.ts";
|
||||||
|
|
||||||
|
|
@ -56,6 +57,46 @@ test("the cap keeps the newest messages and leaves a short list alone", () => {
|
||||||
assert.equal(newest(all, 10), all);
|
assert.equal(newest(all, 10), all);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("arrivals are numbered in order from the id given", () => {
|
||||||
|
const batch = numbered([msg("a", "b", "x"), msg("c", "d", "y")], 7);
|
||||||
|
assert.deepEqual(
|
||||||
|
batch.map((m) => [m.id, m.body]),
|
||||||
|
[
|
||||||
|
[7, "x"],
|
||||||
|
[8, "y"],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(numbered([], 3), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("in a full list the newest id still changes with every arrival", () => {
|
||||||
|
// The page follows the tail on the newest id, because the count stops
|
||||||
|
// moving once the cap drops one message per arrival.
|
||||||
|
let list = numbered(
|
||||||
|
[1, 2, 3].map((n) => msg("a", "b", String(n))),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const seen = new Set();
|
||||||
|
for (let id = 3; id < 8; id++) {
|
||||||
|
list = newest([...list, ...numbered([msg("a", "b", "n")], id)], 3);
|
||||||
|
assert.equal(list.length, 3);
|
||||||
|
seen.add(list[list.length - 1].id);
|
||||||
|
}
|
||||||
|
assert.equal(seen.size, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a number-tagged message keeps its id through the filter and the cap", () => {
|
||||||
|
const all = numbered([msg("atlas", "iris"), msg("a", "b")], 40);
|
||||||
|
assert.deepEqual(
|
||||||
|
filterMessages(all, "atlas").map((m) => m.id),
|
||||||
|
[40],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
newest(all, 1).map((m) => m.id),
|
||||||
|
[41],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("a snippet is the first non-blank line, cut with an ellipsis", () => {
|
test("a snippet is the first non-blank line, cut with an ellipsis", () => {
|
||||||
assert.equal(snippet("\n\n hello there \nsecond", 80), "hello there");
|
assert.equal(snippet("\n\n hello there \nsecond", 80), "hello there");
|
||||||
assert.equal(snippet("abcdefghij", 5), "abcd…");
|
assert.equal(snippet("abcdefghij", 5), "abcd…");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue