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

+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",
);
});