feat(relays): clearer relay health UI + fix broken relay operations

Relay tab improvements so users can see which relays to remove:
- Per-relay status pill: Online (with ping) / Offline / Checking… / Blocked.
- Insecure remote ws:// relays (CSP-blocked) flagged immediately with a hint.
- Offline relays show a "consider removing" hint; their Remove button is
  emphasized.
- Summary banner when relays are down (incl. the all-dead case explaining why
  videos won't load), pointing at Restore defaults / healthy relays.

Also fixes a pre-existing decomposition bug (commit 7300977a): handleRelayOperation
called this.runRelayOperation, but that method lives on the main
ProfileModalController — so ALL relay ops (add/remove/mode/restore) threw
"this.runRelayOperation is not a function". Route via this.mainController.

Tests: relay-insecure-url (isInsecureRelayUrl), relay-operation-routing (routes to
mainController.runRelayOperation, guards no-pubkey). Both mutation-verified.
Build + lint + full unit suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-20 08:27:41 -04:00
1 parent f383cde481
commit 95071fd16b
4 files changed
+262 -3

No files matched your search

+6
View File
@@ -720,6 +720,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
<div
id="relayHealthSuggestion"
class="hidden card border border-status-warning/40 bg-status-warning/10 p-3 text-xs text-text"
role="status"
aria-live="polite"
></div>
<ul id="relayList" class="grid-stack" data-orientation="vertical"></ul>
<div class="space-y-2 px-1">
<label
+157 -3
View File
@@ -23,6 +23,8 @@ export class ProfileRelayController {
this.addRelayButton = document.getElementById("addRelayBtn") || null;
this.restoreRelaysButton = document.getElementById("restoreRelaysBtn") || null;
this.relayHealthStatus = document.getElementById("relayHealthStatus") || null;
this.relayHealthSuggestion =
document.getElementById("relayHealthSuggestion") || null;
this.relayHealthTelemetryToggle = document.getElementById("relayHealthTelemetryOptIn") || null;
this.profileRelayRefreshBtn = document.getElementById("relayListRefreshBtn") || null;
this.profileRestoreRelaysBtn = this.restoreRelaysButton;
@@ -82,10 +84,28 @@ export class ProfileRelayController {
const info = document.createElement("div");
info.className = "flex-1 min-w-0";
const urlRow = document.createElement("div");
urlRow.className = "flex items-center gap-2 flex-wrap";
const urlEl = document.createElement("p");
urlEl.className = "text-sm font-medium text-primary break-all";
urlEl.textContent = entry.url;
// Live connectivity pill. Defaults to "Checking…" until a health snapshot
// arrives (updateRelayHealthIndicators sets the real state). Insecure
// cleartext ws:// relays (non-localhost) are blocked by the browser CSP,
// so flag them as such immediately — they will never connect.
const statusPill = document.createElement("span");
statusPill.dataset.role = "relay-status-pill";
if (this.isInsecureRelayUrl(entry.url)) {
this.applyStatusPill(statusPill, "blocked");
} else {
this.applyStatusPill(statusPill, "checking");
}
urlRow.appendChild(urlEl);
urlRow.appendChild(statusPill);
const statusEl = document.createElement("p");
statusEl.className = "mt-1 text-xs text-muted";
let modeLabel = "Read & write";
@@ -100,9 +120,19 @@ export class ProfileRelayController {
health.className = "flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-2xs text-muted empty:hidden";
health.dataset.role = "relay-health";
info.appendChild(urlEl);
// Actionable hint shown when a relay is unreachable/blocked.
const hint = document.createElement("p");
hint.className = "mt-1 text-2xs text-status-warning empty:hidden";
hint.dataset.role = "relay-hint";
if (this.isInsecureRelayUrl(entry.url)) {
hint.textContent =
"Insecure ws:// relay — blocked by the browser. Remove it or use a wss:// address.";
}
info.appendChild(urlRow);
info.appendChild(statusEl);
info.appendChild(health);
info.appendChild(hint);
const actions = document.createElement("div");
actions.className = "flex items-center gap-2";
@@ -135,6 +165,43 @@ export class ProfileRelayController {
});
}
// A cleartext ws:// relay that is NOT localhost/127.0.0.1 is blocked by the
// app's Content-Security-Policy (connect-src allows wss: and ws://localhost
// only), so it can never connect from the browser.
isInsecureRelayUrl(url) {
if (typeof url !== "string") {
return false;
}
const trimmed = url.trim().toLowerCase();
if (!trimmed.startsWith("ws://")) {
return false;
}
return !(
trimmed.startsWith("ws://localhost") ||
trimmed.startsWith("ws://127.0.0.1")
);
}
// Paint a status pill in place. State is one of:
// "online" | "offline" | "checking" | "blocked".
applyStatusPill(pill, state, detail = "") {
if (!(pill instanceof HTMLElement)) {
return;
}
const base =
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs font-semibold whitespace-nowrap";
const config = {
online: { cls: "bg-status-success/15 text-status-success", label: "● Online" },
offline: { cls: "bg-status-danger/15 text-status-danger", label: "● Offline" },
checking: { cls: "bg-surface-strong/40 text-muted", label: "● Checking…" },
blocked: { cls: "bg-status-danger/15 text-status-danger", label: "● Blocked" },
};
const chosen = config[state] || config.checking;
pill.className = `${base} ${chosen.cls}`;
pill.textContent = detail ? `${chosen.label} · ${detail}` : chosen.label;
pill.dataset.state = state;
}
updateRelayHealthStatus(message = "") {
if (!this.relayHealthStatus) {
return;
@@ -153,6 +220,9 @@ export class ProfileRelayController {
return;
}
let deadCount = 0;
let totalCount = 0;
snapshot.forEach((entry) => {
const url = entry.url;
const item = this.relayList.querySelector(
@@ -161,12 +231,58 @@ export class ProfileRelayController {
if (!item) {
return;
}
totalCount += 1;
const healthContainer = item.querySelector('[data-role="relay-health"]');
const pill = item.querySelector('[data-role="relay-status-pill"]');
const hint = item.querySelector('[data-role="relay-hint"]');
// Classify connectivity from the snapshot. A relay that has been checked
// (lastCheckedAt) and is not connected is dead; one never checked is still
// "checking". Insecure ws:// relays are blocked regardless.
const insecure = this.isInsecureRelayUrl(url);
const checked = Boolean(entry.lastCheckedAt);
let state;
if (insecure) {
state = "blocked";
} else if (entry.connected) {
state = "online";
} else if (checked) {
state = "offline";
} else {
state = "checking";
}
const isDead = state === "offline" || state === "blocked";
if (isDead) {
deadCount += 1;
}
if (pill) {
const detail =
state === "online" && Number.isFinite(entry.lastLatencyMs)
? `${entry.lastLatencyMs}ms`
: "";
this.applyStatusPill(pill, state, detail);
}
// Draw attention to dead relays' Remove button so they're easy to spot.
const removeBtn = item.querySelector('button[data-variant="danger"]');
if (removeBtn instanceof HTMLElement) {
removeBtn.dataset.emphasis = isDead ? "true" : "false";
}
if (hint instanceof HTMLElement && !insecure) {
// (insecure hint is set at populate time and never changes)
hint.textContent =
state === "offline"
? "Unreachable right now — consider removing it to speed up loading."
: "";
}
if (!healthContainer) {
return;
}
healthContainer.textContent = "";
const createBadge = (label, value, colorClass) => {
@@ -197,6 +313,41 @@ export class ProfileRelayController {
);
}
});
this.updateRelaySuggestion(deadCount, totalCount);
}
// Summary banner above the list: when relays are dead/blocked, nudge the user
// to clean up (and point at "Restore defaults" when the list is badly broken).
updateRelaySuggestion(deadCount, totalCount) {
const banner = this.relayHealthSuggestion;
if (!(banner instanceof HTMLElement)) {
return;
}
if (!deadCount || !totalCount) {
banner.classList.add("hidden");
banner.textContent = "";
return;
}
banner.textContent = "";
const headline = document.createElement("p");
headline.className = "font-semibold text-status-warning";
headline.textContent =
deadCount >= totalCount
? `None of your ${totalCount} relays are reachable.`
: `${deadCount} of ${totalCount} relays are unreachable.`;
banner.appendChild(headline);
const tip = document.createElement("p");
tip.className = "mt-1 text-muted";
tip.textContent =
deadCount >= totalCount
? "Videos can't load without a working relay. Use “Restore defaults” below, or add a healthy relay like wss://relay.damus.io or wss://nos.lol."
: "Removing unreachable relays speeds up loading. Look for the red “Offline” tags below.";
banner.appendChild(tip);
banner.classList.remove("hidden");
}
handleRelayHealthTelemetryToggle() {
@@ -286,7 +437,10 @@ export class ProfileRelayController {
let result;
try {
result = await this.runRelayOperation({
// runRelayOperation lives on the main ProfileModalController (it owns the
// onRelayOperation callback); it was not moved when relay logic was
// extracted into this controller, so it must be called via mainController.
result = await this.mainController.runRelayOperation({
...meta,
activePubkey,
skipPublishIfUnchanged,
+32
View File
@@ -0,0 +1,32 @@
// The relay-list UI flags relays that can never connect from the browser so the
// user knows to remove them. A cleartext ws:// relay that is NOT localhost is
// blocked by the app's CSP (connect-src allows wss: and ws://localhost only), so
// it must be reported as insecure/blocked; wss:// and local ws:// must not be.
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { ProfileRelayController } from "../js/ui/profileModal/ProfileRelayController.js";
const controller = Object.create(ProfileRelayController.prototype);
const isInsecure = (url) => controller.isInsecureRelayUrl(url);
test("flags remote cleartext ws:// relays as insecure (CSP-blocked)", () => {
assert.equal(isInsecure("ws://209.122.211.18:4848"), true);
assert.equal(isInsecure("ws://relay.example.com"), true);
assert.equal(isInsecure("WS://Relay.Example.com"), true, "case-insensitive");
});
test("does not flag secure or local relays", () => {
assert.equal(isInsecure("wss://relay.damus.io"), false);
assert.equal(isInsecure("wss://nos.lol"), false);
assert.equal(isInsecure("ws://localhost:8000"), false);
assert.equal(isInsecure("ws://127.0.0.1:4848"), false);
});
test("handles empty/invalid input safely", () => {
assert.equal(isInsecure(""), false);
assert.equal(isInsecure(null), false);
assert.equal(isInsecure(undefined), false);
assert.equal(isInsecure(12345), false);
});
+67
View File
@@ -0,0 +1,67 @@
// Regression: relay add/remove/mode/restore all funnel through
// handleRelayOperation, which must call runRelayOperation on the MAIN profile
// controller (it owns the onRelayOperation callback). When relay logic was
// extracted into ProfileRelayController, the call was left as
// `this.runRelayOperation` — undefined on the relay controller — so every relay
// operation threw "this.runRelayOperation is not a function". This guards the
// routing so removal/add actually reach the operation handler.
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { ProfileRelayController } from "../js/ui/profileModal/ProfileRelayController.js";
const PK = "a".repeat(64);
function makeController() {
const calls = [];
const mainController = {
normalizeHexPubkey: (v) => (typeof v === "string" ? v.trim().toLowerCase() : ""),
getActivePubkey: () => PK,
showError: (msg) => calls.push(["showError", msg]),
showSuccess: (msg) => calls.push(["showSuccess", msg]),
runRelayOperation: async (args) => {
calls.push(["runRelayOperation", args]);
return { ok: true, changed: true };
},
services: {},
callbacks: {},
};
const controller = Object.create(ProfileRelayController.prototype);
controller.mainController = mainController;
// populateProfileRelays / refreshRelayHealthPanel are called after the op;
// stub them so the routing test stays focused and DOM-free.
controller.populateProfileRelays = () => {};
controller.refreshRelayHealthPanel = () => {};
return { controller, calls, mainController };
}
test("handleRelayOperation routes to mainController.runRelayOperation", async () => {
const { controller, calls } = makeController();
const result = await controller.handleRelayOperation(
{ action: "remove", url: "wss://dead.example.com" },
{ successMessage: "Relay removed." },
);
const opCall = calls.find((c) => c[0] === "runRelayOperation");
assert.ok(opCall, "must invoke runRelayOperation on the main controller");
assert.equal(opCall[1].action, "remove");
assert.equal(opCall[1].url, "wss://dead.example.com");
assert.equal(opCall[1].activePubkey, PK, "forwards the active pubkey");
assert.equal(result.ok, true);
assert.equal(result.changed, true);
});
test("handleRelayOperation surfaces a login error when there is no active pubkey", async () => {
const { controller, calls, mainController } = makeController();
mainController.getActivePubkey = () => "";
const result = await controller.handleRelayOperation({ action: "remove", url: "wss://x" });
assert.equal(result.reason, "no-active-pubkey");
assert.ok(
!calls.some((c) => c[0] === "runRelayOperation"),
"must not attempt the operation without a pubkey",
);
});