1256 lines
50 KiB
JavaScript
1256 lines
50 KiB
JavaScript
"use strict";
|
|
|
|
/* The page: fetches the cluster, draws the map once into persistent SVG elements and
|
|
then only changes attributes, so what has focus or is being dragged is never rebuilt.
|
|
All decisions live in model.js, layout.js and viewport.js. */
|
|
(function () {
|
|
const C = globalThis.Colony;
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
const REFRESH_MS = 1000;
|
|
const STORAGE_KEY = "colony.map.v1";
|
|
const PAD = 48;
|
|
const FIT_MS = 260;
|
|
const COMPACT_MAX_WIDTH = 600;
|
|
|
|
const stage = document.getElementById("stage");
|
|
const svgRoot = document.getElementById("map");
|
|
const world = document.getElementById("world");
|
|
const [viewpointBox, summaryBox, alertsBox, ondemandBox, readerList, liveRegion, errorBanner, updatedLabel] = ["viewpoint", "summary", "alerts", "ondemand", "reader", "live", "error", "updated"].map((id) => document.getElementById(id));
|
|
const layers = {
|
|
spokes: document.getElementById("layer-spokes"),
|
|
regions: document.getElementById("layer-regions"),
|
|
ports: document.getElementById("layer-ports"),
|
|
ends: document.getElementById("layer-ends"),
|
|
legs: document.getElementById("layer-legs"),
|
|
nodes: document.getElementById("layer-nodes"),
|
|
};
|
|
|
|
const svg = (name, attributes, text) => {
|
|
const element = document.createElementNS(SVG_NS, name);
|
|
Object.keys(attributes || {}).forEach((key) => element.setAttribute(key, attributes[key]));
|
|
if (text !== undefined) element.textContent = text;
|
|
return element;
|
|
};
|
|
const html = (name, text, className) => {
|
|
const element = document.createElement(name);
|
|
if (text !== undefined) element.textContent = text;
|
|
if (className) element.className = className;
|
|
return element;
|
|
};
|
|
/* Write an attribute only when it changed. */
|
|
const put = (element, name, value) => {
|
|
const seen = element.__seen || (element.__seen = {});
|
|
if (seen[name] === value) return;
|
|
seen[name] = value;
|
|
element.setAttribute(name, value);
|
|
};
|
|
const putText = (element, value) => {
|
|
if (element.__text === value) return;
|
|
element.__text = value;
|
|
element.textContent = value;
|
|
};
|
|
const putClass = (element, value) => put(element, "class", value);
|
|
const reducedMotion = () => window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
|
|
/* ------------------------------------------------------------------ storage */
|
|
|
|
const store = {
|
|
load() {
|
|
try {
|
|
return C.parseOverrides(window.localStorage.getItem(STORAGE_KEY) || "");
|
|
} catch (error) {
|
|
return C.newOverrides();
|
|
}
|
|
},
|
|
save(value) {
|
|
try {
|
|
window.localStorage.setItem(STORAGE_KEY, C.serializeOverrides(value));
|
|
} catch (error) {
|
|
/* private mode or full: the map still works, it just forgets */
|
|
}
|
|
},
|
|
};
|
|
let saveTimer = null;
|
|
const saveSoon = () => {
|
|
clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(() => store.save(overrides), 300);
|
|
};
|
|
|
|
/* -------------------------------------------------------------------- state */
|
|
|
|
let overrides = store.load();
|
|
let layoutState = C.newState();
|
|
let model = null;
|
|
let map = null;
|
|
let routes = null;
|
|
let trace = null;
|
|
let mapSignature = "";
|
|
let lastCompute = 0;
|
|
let view = { k: 1, tx: 0, ty: 0 };
|
|
let size = { w: stage.clientWidth || 800, h: stage.clientHeight || 600 };
|
|
let autoFit = true;
|
|
let lod;
|
|
let selection = null;
|
|
let hovered = null;
|
|
let focusKey = null;
|
|
let gesture = C.initialGesture();
|
|
let drag = null;
|
|
let lastPressed = null;
|
|
let tween = null;
|
|
const nodeRecords = new Map();
|
|
const regionRecords = new Map();
|
|
let spokeRecords = [];
|
|
|
|
/* A pinned node keeps the trace; hovering another node only previews while nothing is pinned. */
|
|
const selectionLocks = () => Boolean(selection && selection.kind === "node");
|
|
const selectedNet = () => (selection && selection.kind === "network" ? selection.id : null);
|
|
const isPinned = (key) => Boolean(selection && selection.kind === "node" && selection.id === key);
|
|
const viewKey = () => C.resolveView(model, hovered || (selectionLocks() ? selection.id : null));
|
|
|
|
/* --------------------------------------------------------------------- view */
|
|
|
|
function applyView() {
|
|
world.style.transform = "translate(" + view.tx + "px," + view.ty + "px) scale(" + view.k + ")";
|
|
const level = C.lodOf(view.k, lod);
|
|
if (level !== lod) {
|
|
lod = level;
|
|
svgRoot.classList.remove("lod0", "lod1", "lod2");
|
|
svgRoot.classList.add("lod" + level);
|
|
}
|
|
}
|
|
|
|
function setView(next, byUser) {
|
|
const bounds = map ? map.bounds : C.EMPTY_BOUNDS;
|
|
view = C.clampView(next, bounds, size);
|
|
if (byUser) autoFit = false;
|
|
applyView();
|
|
updateControls();
|
|
}
|
|
|
|
function animateTo(target, byUser) {
|
|
if (tween) cancelAnimationFrame(tween.frame);
|
|
tween = null;
|
|
if (reducedMotion()) return setView(target, byUser);
|
|
const from = view;
|
|
const start = performance.now();
|
|
const step = (now) => {
|
|
const t = Math.min(1, (now - start) / FIT_MS);
|
|
setView(C.tween(from, target, t), byUser);
|
|
tween = t < 1 ? { frame: requestAnimationFrame(step) } : null;
|
|
};
|
|
tween = { frame: requestAnimationFrame(step) };
|
|
}
|
|
|
|
function fitAll(animate) {
|
|
if (!map) return;
|
|
const target = C.fitView(map.bounds, size, PAD);
|
|
if (animate) animateTo(target, false);
|
|
else setView(target, false);
|
|
autoFit = true;
|
|
updateControls();
|
|
}
|
|
|
|
function regionView(region) {
|
|
return C.viewFor({ x: region.x - 40, y: region.y - 40, w: region.w + 80, h: region.h + 80 }, size, 24);
|
|
}
|
|
|
|
function fitRegion(net) {
|
|
const region = map && map.regionAt.get(net);
|
|
if (!region) return;
|
|
animateTo(regionView(region), true);
|
|
}
|
|
|
|
// On a phone, fitting the whole map shrinks it below the zoom where names and stats are drawn,
|
|
// so the first view is the region of this node instead. COMPACT_MAX_WIDTH matches the phone
|
|
// breakpoint in style.css. This only sets `view` and `autoFit`; the caller applies the view afterwards.
|
|
let startedCompact = false;
|
|
function startCompact() {
|
|
if (startedCompact) return;
|
|
startedCompact = true; // decided once, on the first map: later changes to the window or the topology must not move the view
|
|
if (size.w > COMPACT_MAX_WIDTH) return;
|
|
const self = model.nodes.find((node) => node.self);
|
|
const region = self && map.regionAt.get(self.net);
|
|
if (!region) return;
|
|
autoFit = false;
|
|
view = regionView(region);
|
|
}
|
|
|
|
function resize() {
|
|
const next = { w: stage.clientWidth || size.w, h: stage.clientHeight || size.h };
|
|
if (next.w === size.w && next.h === size.h) return;
|
|
const bounds = map ? map.bounds : C.EMPTY_BOUNDS;
|
|
svgRoot.setAttribute("viewBox", "0 0 " + next.w + " " + next.h);
|
|
view = C.clampView(C.resizeView(view, size, next, autoFit, bounds, PAD), bounds, next);
|
|
size = next;
|
|
applyView();
|
|
}
|
|
|
|
/* ------------------------------------------------------------ drawing: map */
|
|
|
|
/* One bar of hardware: "CPU 9% · 6C/12T" over a track with a fill. Nodes and networks both use it. */
|
|
function statBar(parent, width) {
|
|
const label = svg("text", { class: "st-text" });
|
|
const name = svg("tspan", { class: "st-name" });
|
|
const value = svg("tspan", { class: "st-value" });
|
|
label.appendChild(name);
|
|
label.appendChild(value);
|
|
const track = svg("rect", { class: "st-track", height: 5, rx: 2.5 });
|
|
const fill = svg("rect", { class: "st-fill", height: 5, rx: 2.5 });
|
|
if (width) put(track, "width", width);
|
|
[label, track, fill].forEach((part) => parent.appendChild(part));
|
|
return { label: label, name: name, value: value, track: track, fill: fill };
|
|
}
|
|
|
|
function paintBar(bar, name, usage, width) {
|
|
putText(bar.name, name + " ");
|
|
putText(bar.value, usage ? usage.text : "n/a");
|
|
put(bar.fill, "width", usage && usage.pct ? Math.max((width * usage.pct) / 100, 4) : 0);
|
|
putClass(bar.fill, "st-fill " + (usage && usage.pct !== null ? C.barLevel(usage.pct) : "ok"));
|
|
}
|
|
|
|
const FOOT_PAD = 14;
|
|
const FOOT_GAP = 8;
|
|
const footColumn = (region) => (region.w - 2 * FOOT_PAD - (C.STAT_COUNT - 1) * FOOT_GAP) / C.STAT_COUNT;
|
|
|
|
function regionRecord(region) {
|
|
let record = regionRecords.get(region.id);
|
|
if (record) return record;
|
|
const group = svg("g", { class: "region" });
|
|
const box = svg("rect", { class: "region-box", rx: 16 });
|
|
const grab = svg("rect", { class: "region-grab", height: 38, rx: 16 });
|
|
const dot = svg("circle", { class: "region-dot", cx: 21, cy: 22, r: 5 });
|
|
const title = svg("text", { class: "region-title", x: 33, y: 27 });
|
|
const count = svg("tspan", { class: "region-count", dx: 10 });
|
|
title.appendChild(document.createTextNode(""));
|
|
title.appendChild(count);
|
|
[box, grab, dot, title].forEach((part) => group.appendChild(part));
|
|
[box, grab].forEach((part) => (part.dataset.net = region.id));
|
|
|
|
/* The network's hardware added up: the same three bars a node has, across the bottom. */
|
|
const foot = svg("g", { class: "stats foot" });
|
|
const rule = svg("line", { class: "foot-rule" });
|
|
const columns = Array.from({ length: C.STAT_COUNT }, () => statBar(foot));
|
|
const note = svg("text", { class: "st-foot" });
|
|
const hint = svg("title");
|
|
[rule, note, hint].forEach((part) => foot.appendChild(part));
|
|
group.appendChild(foot);
|
|
layers.regions.appendChild(group);
|
|
record = { group: group, box: box, grab: grab, title: title, count: count, rule: rule, columns: columns, note: note, hint: hint };
|
|
regionRecords.set(region.id, record);
|
|
return record;
|
|
}
|
|
|
|
function prune(records, present) {
|
|
records.forEach((record, key) => {
|
|
if (present.has(key)) return;
|
|
record.group.remove();
|
|
records.delete(key);
|
|
});
|
|
}
|
|
|
|
/* Three columns across the bottom band of a network. */
|
|
function placeFooter(record, region) {
|
|
const top = region.footer.y - region.y;
|
|
const column = footColumn(region);
|
|
put(record.rule, "x1", FOOT_PAD);
|
|
put(record.rule, "x2", region.w - FOOT_PAD);
|
|
put(record.rule, "y1", top + 2);
|
|
put(record.rule, "y2", top + 2);
|
|
record.columns.forEach((col, index) => {
|
|
const x = FOOT_PAD + index * (column + FOOT_GAP);
|
|
put(col.label, "x", x);
|
|
put(col.label, "y", top + 20);
|
|
put(col.track, "x", x);
|
|
put(col.track, "y", top + 26);
|
|
put(col.track, "width", column);
|
|
put(col.fill, "x", x);
|
|
put(col.fill, "y", top + 26);
|
|
});
|
|
put(record.note, "x", FOOT_PAD);
|
|
put(record.note, "y", top + 50);
|
|
}
|
|
|
|
function drawRegions() {
|
|
const present = new Set();
|
|
map.regions.forEach((region) => {
|
|
present.add(region.id);
|
|
const record = regionRecord(region);
|
|
record.group.style.transform = "translate(" + region.x + "px," + region.y + "px)";
|
|
put(record.box, "width", region.w);
|
|
put(record.box, "height", region.h);
|
|
put(record.grab, "width", region.w);
|
|
placeFooter(record, region);
|
|
const pinnedNet = selectedNet();
|
|
const state = pinnedNet ? (pinnedNet === region.id ? " selected" : " dim") : "";
|
|
putClass(record.group, "region n" + region.color + state + (region.moved ? " moved" : ""));
|
|
record.title.firstChild.textContent = region.id;
|
|
putText(record.count, region.alive === region.count ? region.count + (region.count === 1 ? " node" : " nodes") : region.alive + "/" + region.count + " alive");
|
|
});
|
|
prune(regionRecords, present);
|
|
}
|
|
|
|
function makeNodeRecord(node) {
|
|
const group = svg("g", { class: "node", role: "button", tabindex: "-1" });
|
|
group.dataset.key = node.key;
|
|
const title = svg("title");
|
|
const halo = svg("circle", { class: "halo" });
|
|
const shape = node.gateway ? svg("rect", { class: "disc", rx: 9 }) : svg("circle", { class: "disc" });
|
|
const dot = svg("circle", { class: "dot" });
|
|
const texts = { name: svg("text", { class: "l-name" }), roles: [svg("text", { class: "l-roles" }), svg("text", { class: "l-roles" })], caption: svg("text", { class: "l-caption" }) };
|
|
[title, halo, shape, dot, texts.name, texts.roles[0], texts.roles[1], texts.caption].forEach((part) => group.appendChild(part));
|
|
|
|
const badge = svg("g", { class: "badge" });
|
|
const badgeText = svg("text", { "text-anchor": "middle", y: 3.5 });
|
|
badge.appendChild(svg("circle", { r: 9 }));
|
|
badge.appendChild(badgeText);
|
|
|
|
/* Hardware: three bars with their numbers, and a footer. */
|
|
const stats = svg("g", { class: "stats" });
|
|
const rows = Array.from({ length: C.STAT_COUNT }, () => statBar(stats, C.STAT_W));
|
|
const footer = svg("text", { class: "st-foot" });
|
|
stats.appendChild(footer);
|
|
group.appendChild(stats);
|
|
group.appendChild(badge);
|
|
|
|
layers.nodes.appendChild(group);
|
|
return { group: group, title: title, halo: halo, shape: shape, dot: dot, texts: texts, stats: stats, rows: rows, footer: footer, badge: badge, badgeText: badgeText, gateway: node.gateway };
|
|
}
|
|
|
|
function drawNodes() {
|
|
const present = new Set();
|
|
map.nodes.forEach((node) => {
|
|
present.add(node.key);
|
|
let record = nodeRecords.get(node.key);
|
|
if (record && record.gateway !== node.gateway) {
|
|
record.group.remove();
|
|
record = null;
|
|
}
|
|
if (!record) {
|
|
record = makeNodeRecord(node);
|
|
nodeRecords.set(node.key, record);
|
|
}
|
|
record.node = node;
|
|
if (drag && drag.key === node.key) return;
|
|
const info = model.byKey.get(node.key);
|
|
|
|
record.group.style.transform = "translate(" + node.x + "px," + node.y + "px)";
|
|
const r = node.r;
|
|
if (node.gateway) {
|
|
put(record.shape, "x", -r);
|
|
put(record.shape, "y", -r);
|
|
put(record.shape, "width", 2 * r);
|
|
put(record.shape, "height", 2 * r);
|
|
} else {
|
|
put(record.shape, "r", r);
|
|
}
|
|
put(record.halo, "r", r + 6);
|
|
put(record.dot, "r", node.gateway ? 5.5 : 4.5);
|
|
|
|
/* Every node, gateway or not, is the same stack: name, roles, caption, then its stats. */
|
|
const lines = C.wrapLabels((node.gateway ? ["gateway"] : []).concat(C.specialRoles(model, info)), 17, 2);
|
|
const first = node.box.y - node.y + 11;
|
|
const place = (element, index, text) => {
|
|
put(element, "x", 0);
|
|
put(element, "y", first + index * 13);
|
|
put(element, "text-anchor", "middle");
|
|
putText(element, text);
|
|
};
|
|
place(record.texts.name, 0, C.shorten(info.hostname, 15));
|
|
record.texts.roles.forEach((element, index) => place(element, 1 + index, lines[index] || ""));
|
|
put(record.texts.caption, "x", 0);
|
|
put(record.texts.caption, "y", first + (1 + lines.length) * 13);
|
|
put(record.texts.caption, "text-anchor", "middle");
|
|
|
|
put(record.stats, "transform", "translate(" + -C.STAT_W / 2 + "," + (node.box.y - node.y + C.STAT_TOP) + ")");
|
|
record.rows.forEach((row, index) => {
|
|
put(row.label, "y", index * C.STAT_ROW + 10);
|
|
put(row.track, "y", index * C.STAT_ROW + 14);
|
|
put(row.fill, "y", index * C.STAT_ROW + 14);
|
|
});
|
|
put(record.footer, "y", C.STAT_COUNT * C.STAT_ROW + 10);
|
|
});
|
|
prune(nodeRecords, present);
|
|
drawUnread();
|
|
}
|
|
|
|
function drawUnread() {
|
|
let total = 0;
|
|
nodeRecords.forEach((record, key) => {
|
|
const info = model.byKey.get(key);
|
|
const count = info ? unreadOf(info.id) : 0;
|
|
total += count;
|
|
const corner = record.node.r * 0.8;
|
|
put(record.badge, "transform", "translate(" + corner + "," + -corner + ")");
|
|
put(record.badge, "display", count ? "inline" : "none");
|
|
putText(record.badgeText, count > 9 ? "9+" : String(count));
|
|
});
|
|
document.title = total ? "(" + total + ") Colony" : "Colony";
|
|
drawBell(total);
|
|
}
|
|
|
|
/* The tooltip and the screen reader's text carry the numbers too, so they follow them. */
|
|
function describe(record, info) {
|
|
const unread = unreadOf(info.id);
|
|
const text = C.describeNode(model, info, routes) + (unread ? "\n" + C.plural(unread, "unread message") : "");
|
|
putText(record.title, text);
|
|
put(record.group, "aria-label", text.replace(/\n/g, ". ") + ". Show routes from this node.");
|
|
}
|
|
|
|
/* The numbers move every second and never change the layout, so they are written on their own. */
|
|
function drawStats() {
|
|
nodeRecords.forEach((record, key) => {
|
|
const info = model.byKey.get(key);
|
|
if (!info) return;
|
|
if (routes) describe(record, info);
|
|
const stats = C.describeStats(info.stats);
|
|
stats.rows.forEach(([name, usage], index) => paintBar(record.rows[index], name, usage, C.STAT_W));
|
|
putText(record.footer, stats.footer);
|
|
});
|
|
|
|
map.regions.forEach((region) => {
|
|
const record = regionRecords.get(region.id);
|
|
const totals = C.networkStats(model, region.id);
|
|
totals.rows.forEach(([name, usage], index) => paintBar(record.columns[index], name, usage, footColumn(region)));
|
|
putText(record.note, totals.nodes ? "total of " + C.plural(totals.nodes, "node") + " on " + C.plural(totals.machines, "machine") : totals.footer);
|
|
putText(record.hint, "Cumulative hardware of " + region.id + ": " + C.describeRows(totals.rows).join(", ") + ". Nodes that report the same totals are counted as one machine.");
|
|
});
|
|
}
|
|
|
|
/* A gateway's door on the edge of its network, joined to the gateway node inside. */
|
|
function drawPorts() {
|
|
layers.ports.replaceChildren();
|
|
map.nodes.filter((node) => node.gateway).forEach((node) => {
|
|
const cls = "n" + node.color;
|
|
layers.ports.appendChild(svg("line", { class: "port-link " + cls, x1: node.port.x, y1: node.port.y, x2: node.x, y2: node.y }));
|
|
layers.ports.appendChild(svg("rect", { class: "port " + cls, x: node.port.x - 9, y: node.port.y - 9, width: 18, height: 18, rx: 5 }));
|
|
});
|
|
}
|
|
|
|
function drawSpokes() {
|
|
layers.spokes.replaceChildren();
|
|
spokeRecords = map.spokes.map((spoke) => {
|
|
const path = svg("path", { class: "spoke n" + spoke.color, d: spoke.d });
|
|
layers.spokes.appendChild(path);
|
|
return { spoke: spoke, path: path };
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------- drawing: the trace */
|
|
|
|
function drawTrace() {
|
|
routes = C.routesFrom(model, viewKey());
|
|
trace = C.traceGeometry(map, model, routes);
|
|
|
|
layers.legs.replaceChildren();
|
|
layers.ends.replaceChildren();
|
|
trace.legs.forEach((leg) => {
|
|
layers.legs.appendChild(svg("path", { class: "flow n" + leg.color, d: leg.d, "marker-end": "url(#arrow-" + leg.color + ")" }));
|
|
});
|
|
const shown = viewKey();
|
|
const pinnedNet = selectedNet();
|
|
spokeRecords.forEach(({ spoke, path }) => {
|
|
const used = trace.used.has(spoke.gateway + ">" + spoke.to);
|
|
layers.ends.appendChild(svg("circle", { class: "spoke-end n" + spoke.color + (used || isPinned(spoke.gateway) ? " used" : ""), cx: spoke.x2, cy: spoke.y2, r: used ? 5.5 : 4 }));
|
|
if (used) layers.ends.appendChild(svg("path", { class: "flow n" + spoke.color, d: spoke.d, "marker-end": "url(#arrow-" + spoke.color + ")" }));
|
|
const lit = pinnedNet && (spoke.to === pinnedNet || (model.byKey.get(spoke.gateway) || {}).net === pinnedNet);
|
|
putClass(path, "spoke n" + spoke.color + (used ? " hidden" : "") + (lit ? " lit" : ""));
|
|
});
|
|
|
|
nodeRecords.forEach((record, key) => {
|
|
const node = record.node;
|
|
const info = model.byKey.get(key);
|
|
const kind = trace.kinds.get(key);
|
|
const classes = ["node", "n" + node.color, info.alive ? "alive" : "down"];
|
|
if (node.gateway) classes.push("gateway");
|
|
if (key === shown) classes.push("origin");
|
|
if (isPinned(key)) classes.push("selected");
|
|
if (kind === "none" || kind === "unknown") classes.push(kind);
|
|
if (kind === "via") classes.push("reached");
|
|
if (key === focusKey || key === hovered || key === shown) classes.push("full");
|
|
if (pinnedNet && pinnedNet !== node.net) classes.push("dim");
|
|
putClass(record.group, classes.join(" "));
|
|
|
|
putText(record.texts.caption, trace.captions.get(key) || "");
|
|
describe(record, info);
|
|
put(record.group, "aria-pressed", isPinned(key) ? "true" : "false");
|
|
put(record.group, "tabindex", key === focusKey ? "0" : "-1");
|
|
});
|
|
|
|
drawViewpoint();
|
|
}
|
|
|
|
function drawViewpoint() {
|
|
const box = viewpointBox;
|
|
box.replaceChildren();
|
|
const viewer = model.byKey.get(routes.viewKey);
|
|
if (!viewer || model.nodes.filter((node) => node.alive).length < 2) return;
|
|
|
|
box.appendChild(html("span", "Routes from"));
|
|
box.appendChild(html("strong", viewer.hostname));
|
|
C.routeSummary(model, routes).forEach((part) => {
|
|
const region = part.network === undefined ? null : map.regionAt.get(part.network);
|
|
box.appendChild(html("span", part.text, "route " + part.kind + (region ? " n" + region.color : "")));
|
|
});
|
|
box.appendChild(html("span", selectionLocks() ? "Pinned. Select it again to release." : "Hover a node to trace it, select to pin.", "muted hint"));
|
|
}
|
|
|
|
function drawHeader() {
|
|
summaryBox.textContent = C.summarize(model);
|
|
const box = alertsBox;
|
|
box.replaceChildren();
|
|
C.alerts(model).forEach((alert) => box.appendChild(html("span", alert.text, "alert " + alert.level)));
|
|
ondemandBox.textContent = C.onDemandHint(model);
|
|
}
|
|
|
|
/* A plain list of the same facts, for screen readers. */
|
|
function drawReaderList() {
|
|
const list = readerList;
|
|
list.replaceChildren();
|
|
model.networks.forEach((net) => {
|
|
const names = model.nodes.filter((node) => node.net === net).map((node) => node.hostname + (model.bridgeAt.has(node.key) ? " (gateway)" : "") + (node.alive ? "" : " (down)"));
|
|
list.appendChild(html("li", net + ": " + names.join(", ")));
|
|
});
|
|
}
|
|
|
|
const announce = (text) => {
|
|
liveRegion.textContent = text;
|
|
};
|
|
|
|
/* --------------------------------------------------------------- the update */
|
|
|
|
function recompute(now) {
|
|
map = C.computeMap(model, layoutState, overrides, now);
|
|
lastCompute = now;
|
|
drawRegions();
|
|
drawNodes();
|
|
drawSpokes();
|
|
drawPorts();
|
|
startCompact();
|
|
if (autoFit) fitAll(false);
|
|
else setView(view, false);
|
|
drawTrace();
|
|
drawStats();
|
|
updateControls();
|
|
mapSignature = model.signature;
|
|
}
|
|
|
|
function update(payload) {
|
|
model = C.analyze(payload);
|
|
const now = Date.now();
|
|
if (focusKey && !model.byKey.has(focusKey)) focusKey = null;
|
|
if (!focusKey) focusKey = model.selfKey || (model.nodes[0] || {}).key || null;
|
|
if (selection && !(selection.kind === "network" ? model.networks.includes(selection.id) : model.byKey.has(selection.id))) selection = null;
|
|
if (hovered && !model.byKey.has(hovered)) hovered = null;
|
|
|
|
drawHeader();
|
|
C.noteGateways(model, layoutState, now);
|
|
const ghosts = layoutState.gateways.size > model.bridges.length;
|
|
if (model.signature !== mapSignature || (ghosts && now - lastCompute > 5000)) {
|
|
recompute(now);
|
|
drawReaderList();
|
|
} else {
|
|
drawStats();
|
|
}
|
|
updateChat();
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- controls */
|
|
|
|
const legend = document.getElementById("legend");
|
|
const legendButton = document.getElementById("legend-open");
|
|
const resetButton = document.getElementById("reset");
|
|
|
|
function updateControls() {
|
|
resetButton.hidden = !C.hasOverrides(overrides);
|
|
legend.hidden = !overrides.legend;
|
|
legendButton.hidden = overrides.legend;
|
|
document.getElementById("fit").classList.toggle("on", autoFit);
|
|
}
|
|
|
|
function zoomStep(factor) {
|
|
cancelTween();
|
|
setView(C.zoomAt(view, size.w / 2, size.h / 2, factor, map && map.bounds, size), true);
|
|
}
|
|
|
|
document.getElementById("zoom-in").addEventListener("click", () => zoomStep(1.25));
|
|
document.getElementById("zoom-out").addEventListener("click", () => zoomStep(0.8));
|
|
document.getElementById("fit").addEventListener("click", () => fitAll(true));
|
|
resetButton.addEventListener("click", () => {
|
|
const legendWas = overrides.legend;
|
|
overrides = C.newOverrides();
|
|
overrides.legend = legendWas;
|
|
layoutState = C.newState();
|
|
store.save(overrides);
|
|
recompute(Date.now());
|
|
fitAll(true);
|
|
announce("Layout reset");
|
|
});
|
|
document.getElementById("legend-close").addEventListener("click", () => {
|
|
overrides.legend = false;
|
|
saveSoon();
|
|
updateControls();
|
|
});
|
|
legendButton.addEventListener("click", () => {
|
|
overrides.legend = true;
|
|
saveSoon();
|
|
updateControls();
|
|
});
|
|
|
|
/* ------------------------------------------------------------ pointer input */
|
|
|
|
const local = (event) => {
|
|
const box = svgRoot.getBoundingClientRect();
|
|
return { x: event.clientX - box.left, y: event.clientY - box.top };
|
|
};
|
|
|
|
function targetOf(element) {
|
|
const node = element.closest && element.closest(".node");
|
|
if (node) return { kind: node.classList.contains("gateway") ? "gateway" : "node", key: node.dataset.key };
|
|
const grab = element.closest && element.closest(".region-grab");
|
|
if (grab) return { kind: "region-title", key: grab.dataset.net };
|
|
const box = element.closest && element.closest(".region-box");
|
|
if (box) return { kind: "region", key: box.dataset.net };
|
|
return { kind: "background", key: null };
|
|
}
|
|
|
|
function select(next) {
|
|
selection = next;
|
|
hovered = null;
|
|
if (next && next.kind === "node") announce("Tracing routes from " + model.byKey.get(next.id).hostname);
|
|
else if (next && next.kind === "network") announce("Network " + next.id + " selected");
|
|
else announce("Selection cleared");
|
|
drawRegions();
|
|
drawTrace();
|
|
updateChat();
|
|
}
|
|
|
|
const same = (a, b) => Boolean(a && b && a.kind === b.kind && a.id === b.id);
|
|
const toggle = (next) => select(same(selection, next) ? null : next);
|
|
|
|
function cancelTween() {
|
|
if (tween) cancelAnimationFrame(tween.frame);
|
|
tween = null;
|
|
}
|
|
|
|
function perform(action) {
|
|
if (["pan", "zoom", "drag-start"].includes(action.type)) cancelTween();
|
|
if (action.type === "pan") setView(C.panBy(view, action.dx, action.dy), true);
|
|
else if (action.type === "zoom") setView(C.zoomAt(view, action.x, action.y, action.factor, map && map.bounds, size), true);
|
|
else if (action.type === "fit") {
|
|
if (action.target && action.target.kind.startsWith("region")) fitRegion(action.target.key);
|
|
else fitAll(true);
|
|
} else if (action.type === "click") {
|
|
const target = action.target;
|
|
if (target.kind === "node" || target.kind === "gateway") {
|
|
focusKey = target.key;
|
|
toggle({ kind: "node", id: target.key });
|
|
} else if (target.kind === "region" || target.kind === "region-title") toggle({ kind: "network", id: target.key });
|
|
else if (selection) select(null);
|
|
} else if (action.type === "drag-start") startDrag(action.target);
|
|
else if (action.type === "drag-move") moveDrag(action);
|
|
else if (action.type === "drag-end") endDrag(true);
|
|
else if (action.type === "drag-cancel") endDrag(false);
|
|
}
|
|
|
|
function startDrag(target) {
|
|
if (target.kind === "region-title") {
|
|
const region = map.regionAt.get(target.key);
|
|
drag = { kind: "region", net: target.key, x: region.x, y: region.y, before: overrides.regions[target.key] };
|
|
autoFit = false;
|
|
updateControls();
|
|
return;
|
|
}
|
|
const node = map.nodeAt.get(target.key);
|
|
if (!node) return;
|
|
drag = { kind: target.kind, key: target.key, net: node.net, x: node.x, y: node.y, from: node.cell };
|
|
nodeRecords.get(target.key).group.classList.add("dragging");
|
|
}
|
|
|
|
let dragFrame = null;
|
|
function moveDrag(action) {
|
|
if (!drag) return;
|
|
drag.x += action.dx / view.k;
|
|
drag.y += action.dy / view.k;
|
|
if (drag.kind === "region") {
|
|
C.setRegion(overrides, drag.net, { x: drag.x, y: drag.y, cols: (overrides.regions[drag.net] || {}).cols });
|
|
if (dragFrame) return;
|
|
dragFrame = requestAnimationFrame(() => {
|
|
dragFrame = null;
|
|
if (drag) recompute(Date.now());
|
|
});
|
|
} else if (nodeRecords.has(drag.key)) {
|
|
nodeRecords.get(drag.key).group.style.transform = "translate(" + drag.x + "px," + drag.y + "px)";
|
|
}
|
|
}
|
|
|
|
function endDrag(keep) {
|
|
if (!drag) return;
|
|
const done = drag;
|
|
drag = null;
|
|
if (dragFrame) cancelAnimationFrame(dragFrame);
|
|
dragFrame = null;
|
|
if (done.kind !== "region" && nodeRecords.has(done.key)) nodeRecords.get(done.key).group.classList.remove("dragging");
|
|
if (done.kind !== "region" && !map.nodeAt.has(done.key)) return;
|
|
|
|
if (!keep) {
|
|
if (done.kind === "region") {
|
|
if (done.before) overrides.regions[done.net] = done.before;
|
|
else delete overrides.regions[done.net];
|
|
}
|
|
} else if (done.kind === "node") {
|
|
const cell = C.cellAt(map, done.net, done.x, done.y);
|
|
if (cell !== null && cell !== done.from) {
|
|
const other = map.nodes.find((node) => node.net === done.net && node.cell === cell && node.key !== done.key);
|
|
C.setCell(overrides, done.net, done.key, cell);
|
|
if (other) C.setCell(overrides, done.net, other.key, done.from);
|
|
}
|
|
} else if (done.kind === "gateway") {
|
|
C.setDock(overrides, done.key, C.dockAt(map, done.net, done.x, done.y, map.nodeAt.get(done.key).dock.side));
|
|
}
|
|
saveSoon();
|
|
recompute(Date.now());
|
|
}
|
|
|
|
svgRoot.addEventListener("pointerdown", (event) => {
|
|
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
const point = local(event);
|
|
try {
|
|
svgRoot.setPointerCapture(event.pointerId);
|
|
} catch (error) {
|
|
/* a synthetic event has no capturable pointer */
|
|
}
|
|
lastPressed = targetOf(event.target);
|
|
const result = C.reduceGesture(gesture, { type: "down", id: event.pointerId, x: point.x, y: point.y, pointerType: event.pointerType, target: lastPressed, selected: selection ? selection.id : null });
|
|
gesture = result.state;
|
|
result.actions.forEach(perform);
|
|
});
|
|
svgRoot.addEventListener("pointermove", (event) => {
|
|
const point = local(event);
|
|
if (gesture.mode === "idle") {
|
|
if (event.pointerType !== "mouse") return;
|
|
const target = targetOf(event.target);
|
|
const key = target.kind === "node" || target.kind === "gateway" ? target.key : null;
|
|
if (key !== hovered && !selectionLocks()) {
|
|
hovered = key;
|
|
drawTrace();
|
|
}
|
|
return;
|
|
}
|
|
const result = C.reduceGesture(gesture, { type: "move", id: event.pointerId, x: point.x, y: point.y });
|
|
gesture = result.state;
|
|
result.actions.forEach(perform);
|
|
});
|
|
["pointerup", "pointercancel"].forEach((type) => {
|
|
svgRoot.addEventListener(type, (event) => {
|
|
const point = local(event);
|
|
const result = C.reduceGesture(gesture, { type: type === "pointerup" ? "up" : "cancel", id: event.pointerId, x: point.x, y: point.y });
|
|
gesture = result.state;
|
|
result.actions.forEach(perform);
|
|
});
|
|
});
|
|
svgRoot.addEventListener("pointerleave", (event) => {
|
|
const focused = nodeFromElement(document.activeElement);
|
|
if (event.pointerType === "mouse" && hovered && gesture.mode === "idle" && model && !(focused && focused.dataset.key === hovered)) {
|
|
hovered = null;
|
|
drawTrace();
|
|
}
|
|
});
|
|
svgRoot.addEventListener("wheel", (event) => {
|
|
event.preventDefault();
|
|
const point = local(event);
|
|
const result = C.reduceGesture(gesture, { type: "wheel", x: point.x, y: point.y, deltaY: event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY, ctrl: event.ctrlKey });
|
|
result.actions.forEach(perform);
|
|
}, { passive: false });
|
|
svgRoot.addEventListener("dblclick", (event) => {
|
|
/* With the pointer captured, the event's own target is the svg: use what was pressed. */
|
|
C.reduceGesture(gesture, { type: "dblclick", target: lastPressed || targetOf(event.target) }).actions.forEach(perform);
|
|
});
|
|
|
|
/* ----------------------------------------------------------------- keyboard */
|
|
|
|
const nodeFromElement = (element) => (element && element.closest ? element.closest(".node") : null);
|
|
|
|
function focusNode(key) {
|
|
focusKey = key;
|
|
const record = nodeRecords.get(key);
|
|
if (!record) return;
|
|
nodeRecords.forEach((other, id) => put(other.group, "tabindex", id === key ? "0" : "-1"));
|
|
record.group.focus({ preventScroll: true });
|
|
const node = map.nodeAt.get(key);
|
|
const x = node.x * view.k + view.tx;
|
|
const y = node.y * view.k + view.ty;
|
|
const shift = (at, limit) => (at < 40 ? 40 - at : at > limit - 40 ? limit - 40 - at : 0);
|
|
if (shift(x, size.w) || shift(y, size.h)) setView(C.panBy(view, shift(x, size.w), shift(y, size.h)), true);
|
|
}
|
|
|
|
function moveFocus(dx, dy) {
|
|
const from = map.nodeAt.get(focusKey);
|
|
if (!from) return;
|
|
let best = null;
|
|
map.nodes.forEach((node) => {
|
|
const ahead = (node.x - from.x) * dx + (node.y - from.y) * dy;
|
|
if (node === from || ahead <= 0) return;
|
|
const score = ahead + (Math.abs((node.x - from.x) * dy) + Math.abs((node.y - from.y) * dx)) * 2;
|
|
if (!best || score < best.score) best = { node: node, score: score };
|
|
});
|
|
if (best) focusNode(best.node.key);
|
|
}
|
|
|
|
/* Alt+arrows: the keyboard's way of dragging. */
|
|
function nudge(dx, dy) {
|
|
const node = map.nodeAt.get(focusKey);
|
|
if (selectedNet() && !nodeFromElement(document.activeElement)) {
|
|
const region = map.regionAt.get(selectedNet());
|
|
C.setRegion(overrides, region.id, { x: region.x + dx * C.CELL_W, y: region.y + dy * C.CELL_H, cols: (overrides.regions[region.id] || {}).cols });
|
|
} else if (node && node.gateway) {
|
|
const side = node.dock.side;
|
|
const along = side === "left" || side === "right" ? dy : dx;
|
|
const line = map.nodes.filter((other) => other.gateway && other.net === node.net && other.dock.side === side).sort((a, b) => a.dock.index - b.dock.index);
|
|
const target = line.indexOf(node) + along;
|
|
if (!along || target < 0 || target >= line.length) return;
|
|
line.splice(line.indexOf(node), 1);
|
|
line.splice(target, 0, node);
|
|
line.forEach((other, index) => C.setDock(overrides, other.key, { side: side, t: index * 10 }));
|
|
} else if (node) {
|
|
const region = map.regionAt.get(node.net);
|
|
const col = node.col + dx;
|
|
const row = node.row + dy;
|
|
if (col < 0 || col >= region.cols || row < 0 || row > region.rows) return;
|
|
const cell = row * region.cols + col;
|
|
const other = map.nodes.find((entry) => entry.net === node.net && entry.cell === cell);
|
|
C.setCell(overrides, node.net, node.key, cell);
|
|
if (other) C.setCell(overrides, node.net, other.key, node.cell);
|
|
} else {
|
|
return;
|
|
}
|
|
saveSoon();
|
|
recompute(Date.now());
|
|
const record = focusKey && nodeRecords.get(focusKey);
|
|
if (record && document.activeElement !== record.group && nodeFromElement(document.activeElement)) record.group.focus({ preventScroll: true });
|
|
}
|
|
|
|
function nextNetwork(step) {
|
|
const nets = model.networks;
|
|
const at = selectedNet() ? nets.indexOf(selectedNet()) : -1;
|
|
const net = nets[(at + step + nets.length) % nets.length];
|
|
select({ kind: "network", id: net });
|
|
const first = map.nodes.find((node) => node.net === net);
|
|
if (first) focusNode(first.key);
|
|
fitRegion(net);
|
|
}
|
|
|
|
svgRoot.addEventListener("keydown", (event) => {
|
|
if (!map) return;
|
|
const target = nodeFromElement(event.target);
|
|
const action = C.keyAction(event, { onNode: Boolean(target) });
|
|
if (!action) return;
|
|
event.preventDefault();
|
|
if (action.type === "move-focus") moveFocus(action.dx, action.dy);
|
|
else if (action.type === "nudge") nudge(action.dx, action.dy);
|
|
else if (action.type === "zoom-step") zoomStep(action.factor);
|
|
else if (action.type === "fit") fitAll(true);
|
|
else if (action.type === "fit-selected") {
|
|
const net = selectedNet() || (map.nodeAt.get(focusKey) || {}).net;
|
|
if (net) fitRegion(net);
|
|
} else if (action.type === "next-network") nextNetwork(action.step);
|
|
else if (action.type === "clear") select(null);
|
|
else if (action.type === "activate") toggle({ kind: "node", id: target.dataset.key });
|
|
else if (action.type === "pan") setView(C.panBy(view, action.dx, action.dy), true);
|
|
});
|
|
|
|
svgRoot.addEventListener("focusin", (event) => {
|
|
const node = nodeFromElement(event.target);
|
|
if (!node || !map) return;
|
|
focusKey = node.dataset.key;
|
|
if (!selectionLocks() && hovered !== focusKey) {
|
|
hovered = focusKey;
|
|
drawTrace();
|
|
}
|
|
});
|
|
svgRoot.addEventListener("focusout", (event) => {
|
|
if (nodeFromElement(event.relatedTarget) || !map || !hovered) return;
|
|
hovered = null;
|
|
drawTrace();
|
|
});
|
|
|
|
const [chatPanel, chatTitle, chatStatus, chatLog, chatEmpty, chatError, chatForm, chatInput, chatSend, chatClose, chatLive] = ["chat", "chat-title", "chat-status", "chat-log", "chat-empty", "chat-error", "chat-form", "chat-input", "chat-send", "chat-close", "chat-live"].map((id) => document.getElementById(id));
|
|
let chat = { peer: null, after: 0, loaded: false, loading: false };
|
|
|
|
const chatTarget = () => (model && selectionLocks() && selection.id !== model.selfKey ? model.byKey.get(selection.id) : null);
|
|
const chatTime = (at) => {
|
|
const date = new Date(at);
|
|
return isNaN(date) ? "" : date.toLocaleString([], { dateStyle: "short", timeStyle: "short" });
|
|
};
|
|
|
|
function updateChat() {
|
|
const peer = chatTarget();
|
|
chatPanel.hidden = !peer;
|
|
stage.classList.toggle("chatting", Boolean(peer));
|
|
if (!peer) {
|
|
chat = { peer: null, after: 0, loaded: false, loading: false };
|
|
return;
|
|
}
|
|
if (chat.peer !== peer.id) {
|
|
chat = { peer: peer.id, hostname: peer.hostname, after: 0, loaded: false, loading: false };
|
|
chatTitle.textContent = "Chat with " + peer.hostname;
|
|
chatLog.replaceChildren();
|
|
chatEmpty.hidden = true;
|
|
chatError.hidden = true;
|
|
chatInput.value = "";
|
|
}
|
|
const state = C.chatState(model, peer.key);
|
|
const ignored = isIgnored(peer.id);
|
|
const status = [ignored ? "You are ignoring " + peer.hostname + " in this browser: no notifications or unread counts for them." : "", state.text].filter(Boolean).join(" ");
|
|
chatStatus.textContent = status;
|
|
chatStatus.hidden = !status;
|
|
chatForm.hidden = !state.send;
|
|
chatIgnore.hidden = !inbox || !state.read;
|
|
chatIgnore.textContent = ignored ? "Unignore" : "Ignore";
|
|
chatIgnore.title = ignored ? "Get notified about " + peer.hostname + " again" : "Stop notifications and unread counts for " + peer.hostname;
|
|
chatIgnore.setAttribute("aria-pressed", ignored ? "true" : "false");
|
|
if (state.read) loadChat();
|
|
}
|
|
|
|
function paintChatMessages(messages, fresh) {
|
|
const atBottom = chatLog.scrollTop + chatLog.clientHeight >= chatLog.scrollHeight - 8;
|
|
messages.forEach((message) => {
|
|
const item = html("li", undefined, "msg " + (message.direction === "out" ? "out" : "in"));
|
|
item.appendChild(html("span", message.direction === "out" ? "You" : chat.hostname, "msg-from sr-only"));
|
|
item.appendChild(html("p", message.text, "msg-text"));
|
|
const time = html("time", chatTime(message.at), "msg-time");
|
|
time.dateTime = message.at;
|
|
item.appendChild(time);
|
|
chatLog.appendChild(item);
|
|
});
|
|
if (atBottom || !fresh) chatLog.scrollTop = chatLog.scrollHeight;
|
|
const incoming = messages.filter((message) => message.direction === "in");
|
|
if (fresh && incoming.length) chatLive.textContent = chat.hostname + " says: " + incoming[incoming.length - 1].text;
|
|
}
|
|
|
|
async function loadChat() {
|
|
const current = chat;
|
|
if (current.loading || !current.peer) return;
|
|
current.loading = true;
|
|
try {
|
|
const response = await fetch("/api/chat?peer=" + encodeURIComponent(current.peer) + "&after=" + current.after, { cache: "no-store" });
|
|
const body = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(body.error || "the node answered with HTTP " + response.status);
|
|
if (chat !== current) return;
|
|
paintChatMessages(body.messages, current.loaded);
|
|
if (body.messages.length) current.after = body.messages[body.messages.length - 1].seq;
|
|
current.loaded = true;
|
|
if (!document.hidden) markSeen(current.peer);
|
|
chatEmpty.hidden = chatLog.children.length > 0;
|
|
if (chatError.dataset.from === "load") chatError.hidden = true;
|
|
} catch (error) {
|
|
if (chat === current) {
|
|
chatError.textContent = "Cannot load the messages: " + error.message;
|
|
chatError.dataset.from = "load";
|
|
chatError.hidden = false;
|
|
}
|
|
} finally {
|
|
current.loading = false;
|
|
}
|
|
}
|
|
|
|
chatForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const text = chatInput.value.trim();
|
|
const current = chat;
|
|
if (!text || !current.peer || chatSend.disabled) return;
|
|
chatInput.disabled = chatSend.disabled = true;
|
|
try {
|
|
const response = await fetch("/api/chat", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ peer: current.peer, text: text }),
|
|
});
|
|
const body = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(body.error || "the node answered with HTTP " + response.status);
|
|
if (chat === current) {
|
|
chatInput.value = "";
|
|
chatError.hidden = true;
|
|
loadChat();
|
|
}
|
|
} catch (error) {
|
|
if (chat === current) {
|
|
chatError.textContent = "Not sent: " + error.message;
|
|
chatError.dataset.from = "send";
|
|
chatError.hidden = false;
|
|
}
|
|
} finally {
|
|
chatInput.disabled = chatSend.disabled = false;
|
|
if (!chatPanel.hidden) chatInput.focus();
|
|
}
|
|
});
|
|
|
|
/* ------------------------------------------------------------ notifications */
|
|
|
|
const SEEN_KEY = "colony.chat.seen.v1";
|
|
const IGNORED_KEY = "colony.chat.ignored.v1";
|
|
const TOAST_MS = 8000;
|
|
const TOAST_LIMIT = 3;
|
|
const [notifyButton, toastBox, chatIgnore, bellWrap, bell, bellCount, inboxPanel, inboxList, inboxEmpty, inboxRead] = ["notify", "toasts", "chat-ignore", "bell-wrap", "bell", "bell-count", "inbox-panel", "inbox-list", "inbox-empty", "inbox-read"].map((id) => document.getElementById(id));
|
|
let inbox = null;
|
|
let inboxText = "";
|
|
let inboxBusy = false;
|
|
let inboxSignature = "";
|
|
let seen = loadStored(SEEN_KEY);
|
|
const ignoredPeers = new Set(Object.keys(loadStored(IGNORED_KEY) || {}));
|
|
|
|
function loadStored(key) {
|
|
try {
|
|
const value = JSON.parse(window.localStorage.getItem(key));
|
|
return value && typeof value === "object" ? value : null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveStored(key, value) {
|
|
try {
|
|
window.localStorage.setItem(key, JSON.stringify(value));
|
|
} catch (error) {
|
|
/* private mode: this is forgotten with the page */
|
|
}
|
|
}
|
|
|
|
const saveSeen = () => saveStored(SEEN_KEY, seen);
|
|
const isIgnored = (peerId) => ignoredPeers.has(peerId);
|
|
const unreadOf = (peerId) => {
|
|
const entry = inbox && !isIgnored(peerId) ? inbox[peerId] : null;
|
|
return entry ? Math.max(0, entry.incoming - ((seen && seen[peerId]) || 0)) : 0;
|
|
};
|
|
const peerName = (peerId) => {
|
|
const node = model && model.nodes.find((entry) => entry.id === peerId);
|
|
return node ? node.hostname : peerId.split("@")[0];
|
|
};
|
|
|
|
function markSeen(peerId) {
|
|
const entry = inbox && inbox[peerId];
|
|
if (!entry || !seen || seen[peerId] === entry.incoming) return;
|
|
seen[peerId] = entry.incoming;
|
|
saveSeen();
|
|
if (model) drawUnread();
|
|
}
|
|
|
|
function drawBell(total) {
|
|
bellWrap.hidden = !inbox;
|
|
if (!inbox) toggleInbox(false);
|
|
bellCount.hidden = !total;
|
|
bellCount.textContent = total > 99 ? "99+" : String(total);
|
|
bell.classList.toggle("unread", total > 0);
|
|
bell.setAttribute("aria-label", total ? "Messages, " + C.plural(total, "unread message") : "Messages");
|
|
drawInbox();
|
|
}
|
|
|
|
function drawInbox() {
|
|
if (inboxPanel.hidden || !inbox) return;
|
|
const conversations = Object.keys(inbox)
|
|
.filter((peerId) => inbox[peerId].last && !isIgnored(peerId))
|
|
.sort((a, b) => (inbox[b].last.at || "").localeCompare(inbox[a].last.at || ""));
|
|
const signature = JSON.stringify(conversations.map((peerId) => [peerId, peerName(peerId), unreadOf(peerId), inbox[peerId].last]));
|
|
if (signature === inboxSignature) return;
|
|
inboxSignature = signature;
|
|
inboxList.replaceChildren();
|
|
conversations.forEach((peerId) => {
|
|
const unread = unreadOf(peerId);
|
|
const last = inbox[peerId].last;
|
|
const row = html("button", undefined, "inbox-row" + (unread ? " unread" : ""));
|
|
row.type = "button";
|
|
row.appendChild(html("span", peerName(peerId), "inbox-name"));
|
|
const time = html("time", chatTime(last.at), "inbox-time");
|
|
time.dateTime = last.at;
|
|
row.appendChild(time);
|
|
row.appendChild(html("span", last.text, "inbox-text"));
|
|
if (unread) row.appendChild(html("span", unread > 99 ? "99+" : String(unread), "inbox-count"));
|
|
row.setAttribute("aria-label", peerName(peerId) + (unread ? ", " + C.plural(unread, "unread message") : "") + ": " + last.text);
|
|
row.addEventListener("click", () => openChat(peerId));
|
|
const item = html("li");
|
|
item.appendChild(row);
|
|
inboxList.appendChild(item);
|
|
});
|
|
inboxEmpty.hidden = conversations.length > 0;
|
|
inboxRead.disabled = !conversations.some((peerId) => unreadOf(peerId));
|
|
}
|
|
|
|
function toggleInbox(open) {
|
|
if (open === !inboxPanel.hidden) return;
|
|
inboxPanel.hidden = !open;
|
|
inboxSignature = "";
|
|
bell.setAttribute("aria-expanded", open ? "true" : "false");
|
|
if (open) {
|
|
toastBox.replaceChildren();
|
|
updateNotifyButton();
|
|
drawInbox();
|
|
}
|
|
}
|
|
|
|
function markAllRead() {
|
|
Object.keys(inbox).forEach((peerId) => (seen[peerId] = inbox[peerId].incoming));
|
|
saveSeen();
|
|
drawUnread();
|
|
drawStats();
|
|
}
|
|
|
|
function openChat(peerId) {
|
|
const node = model && model.nodes.find((entry) => entry.id === peerId);
|
|
toggleInbox(false);
|
|
if (!node) return;
|
|
focusKey = node.key;
|
|
if (!isPinned(node.key)) select({ kind: "node", id: node.key });
|
|
chatInput.focus();
|
|
}
|
|
|
|
function showToast(peerId, name, text) {
|
|
const toast = html("button", undefined, "toast");
|
|
toast.type = "button";
|
|
toast.appendChild(html("strong", name));
|
|
toast.appendChild(html("span", text));
|
|
toast.addEventListener("click", () => {
|
|
toast.remove();
|
|
openChat(peerId);
|
|
});
|
|
toastBox.appendChild(toast);
|
|
while (toastBox.children.length > TOAST_LIMIT) toastBox.firstChild.remove();
|
|
setTimeout(() => toast.remove(), TOAST_MS);
|
|
}
|
|
|
|
function notifyMessage(peerId, message, count) {
|
|
const name = peerName(peerId);
|
|
const text = count > 1 ? C.plural(count, "new message") + ", last: " + message.text : message.text;
|
|
if (inboxPanel.hidden) showToast(peerId, name, text);
|
|
if (!("Notification" in window) || Notification.permission !== "granted" || (!document.hidden && document.hasFocus())) return;
|
|
const notification = new Notification("Message from " + name, { body: text, tag: "colony-chat-" + peerId });
|
|
notification.addEventListener("click", () => {
|
|
window.focus();
|
|
openChat(peerId);
|
|
notification.close();
|
|
});
|
|
}
|
|
|
|
function handleInbox(next) {
|
|
const previous = inbox;
|
|
inbox = next;
|
|
if (!seen) {
|
|
seen = {};
|
|
Object.keys(next).forEach((peerId) => (seen[peerId] = next[peerId].incoming));
|
|
saveSeen();
|
|
}
|
|
Object.keys(next).forEach((peerId) => {
|
|
const entry = next[peerId];
|
|
const before = previous ? (previous[peerId] || { incoming: 0 }).incoming : entry.incoming;
|
|
if (entry.incoming <= before || isIgnored(peerId) || !entry.last) return;
|
|
if (chat.peer === peerId && !document.hidden) return;
|
|
notifyMessage(peerId, entry.last, entry.incoming - before);
|
|
});
|
|
if (model) {
|
|
drawUnread();
|
|
drawStats();
|
|
updateChat();
|
|
}
|
|
}
|
|
|
|
async function pollInbox() {
|
|
if (inboxBusy) return;
|
|
inboxBusy = true;
|
|
try {
|
|
const response = await fetch("/api/chat/inbox", { cache: "no-store" });
|
|
if (response.status === 409 && inbox) {
|
|
inboxText = "";
|
|
handleInbox(null);
|
|
}
|
|
if (!response.ok) return;
|
|
const text = await response.text();
|
|
if (text === inboxText) return;
|
|
inboxText = text;
|
|
handleInbox(JSON.parse(text));
|
|
} catch (error) {
|
|
/* the status refresh already shows when the node cannot be reached */
|
|
} finally {
|
|
inboxBusy = false;
|
|
}
|
|
}
|
|
|
|
function updateNotifyButton() {
|
|
notifyButton.hidden = !("Notification" in window) || !window.isSecureContext || Notification.permission !== "default";
|
|
}
|
|
|
|
bell.addEventListener("click", () => toggleInbox(inboxPanel.hidden));
|
|
inboxRead.addEventListener("click", markAllRead);
|
|
bellWrap.addEventListener("keydown", (event) => {
|
|
if (event.key !== "Escape" || inboxPanel.hidden) return;
|
|
event.preventDefault();
|
|
toggleInbox(false);
|
|
bell.focus();
|
|
});
|
|
document.addEventListener("pointerdown", (event) => {
|
|
if (!inboxPanel.hidden && !bellWrap.contains(event.target)) toggleInbox(false);
|
|
});
|
|
|
|
notifyButton.addEventListener("click", async () => {
|
|
await Notification.requestPermission();
|
|
updateNotifyButton();
|
|
});
|
|
|
|
chatIgnore.addEventListener("click", () => {
|
|
const peerId = chat.peer;
|
|
if (!peerId) return;
|
|
const ignored = !isIgnored(peerId);
|
|
if (ignored) ignoredPeers.add(peerId);
|
|
else ignoredPeers.delete(peerId);
|
|
saveStored(IGNORED_KEY, Object.fromEntries(Array.from(ignoredPeers, (id) => [id, true])));
|
|
if (!ignored) markSeen(peerId);
|
|
announce((ignored ? "Ignoring " : "No longer ignoring ") + chat.hostname);
|
|
drawUnread();
|
|
drawStats();
|
|
updateChat();
|
|
});
|
|
|
|
chatClose.addEventListener("click", () => select(null));
|
|
chatPanel.addEventListener("keydown", (event) => {
|
|
if (event.key !== "Escape") return;
|
|
event.preventDefault();
|
|
const key = selection && selection.id;
|
|
select(null);
|
|
if (key && model.byKey.has(key)) focusNode(key);
|
|
});
|
|
|
|
/* --------------------------------------------------------------------- data */
|
|
|
|
if (typeof ResizeObserver !== "undefined") new ResizeObserver(resize).observe(stage);
|
|
else window.addEventListener("resize", resize);
|
|
|
|
let busy = false;
|
|
async function refresh() {
|
|
if (busy || document.hidden) return;
|
|
busy = true;
|
|
try {
|
|
const response = await fetch("/api/status?compact=1", { cache: "no-store" });
|
|
if (!response.ok) throw new Error("the node answered with HTTP " + response.status);
|
|
update(await response.json());
|
|
errorBanner.hidden = true;
|
|
updatedLabel.textContent = "Updated " + new Date().toLocaleTimeString();
|
|
} catch (error) {
|
|
const banner = errorBanner;
|
|
banner.textContent = "Cannot reach the node: " + error.message + ". Retrying...";
|
|
banner.hidden = false;
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.hidden) return;
|
|
refresh();
|
|
if (chat.peer && chat.loaded) markSeen(chat.peer);
|
|
});
|
|
svgRoot.setAttribute("viewBox", "0 0 " + size.w + " " + size.h);
|
|
updateControls();
|
|
updateNotifyButton();
|
|
refresh();
|
|
setInterval(refresh, REFRESH_MS);
|
|
pollInbox();
|
|
setInterval(pollInbox, REFRESH_MS);
|
|
})();
|