From 6116b6a7dbd1e3927d590f20159b6735c4a81477 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 19 Jan 2026 14:03:15 -0500
Subject: [PATCH] Add relay health panel and telemetry opt-in
---
components/profile-modal.html | 38 +++++
js/nostr/client.js | 16 +-
js/nostr/dmDecryptDiagnostics.js | 42 ++++++
js/services/relayHealthService.js | 236 ++++++++++++++++++++++++++++++
js/ui/applicationBootstrap.js | 8 +
js/ui/profileModalController.js | 193 ++++++++++++++++++++++++
6 files changed, 527 insertions(+), 6 deletions(-)
create mode 100644 js/nostr/dmDecryptDiagnostics.js
create mode 100644 js/services/relayHealthService.js
diff --git a/components/profile-modal.html b/components/profile-modal.html
index 9ba53b51..62583aee 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -664,6 +664,44 @@
+
+
+
+
Relay health
+
+ Monitor connectivity, latency, and errors for your configured relays.
+
+
+
+
+
+
+ Telemetry is opt-in and only includes relay URLs, latency, and error counts.
+
+
+
+
0,
+ contentLength: typeof event.content === "string" ? event.content.length : 0,
+ tagCount: Array.isArray(event.tags) ? event.tags.length : 0,
+ };
+}
+
+export function sanitizeDecryptError(error) {
+ if (!error) {
+ return null;
+ }
+
+ const message =
+ typeof error.message === "string"
+ ? error.message
+ : typeof error === "string"
+ ? error
+ : "";
+
+ return {
+ name: typeof error.name === "string" ? error.name : "",
+ code: typeof error.code === "string" ? error.code : "",
+ message,
+ };
+}
diff --git a/js/services/relayHealthService.js b/js/services/relayHealthService.js
new file mode 100644
index 00000000..87ed52fc
--- /dev/null
+++ b/js/services/relayHealthService.js
@@ -0,0 +1,236 @@
+import { devLogger, userLogger } from "../utils/logger.js";
+
+const TELEMETRY_STORAGE_KEY = "bitvid:relay-health-telemetry-opt-in";
+
+function resolveLogger(logger) {
+ if (logger && logger.dev && logger.user) {
+ return logger;
+ }
+ return { dev: devLogger, user: userLogger };
+}
+
+function nowMs() {
+ return typeof performance !== "undefined" && performance.now
+ ? performance.now()
+ : Date.now();
+}
+
+function readTelemetryOptIn() {
+ if (typeof window === "undefined") {
+ return false;
+ }
+
+ try {
+ const stored = window.localStorage.getItem(TELEMETRY_STORAGE_KEY);
+ return stored === "true";
+ } catch (error) {
+ userLogger.warn(
+ "[relayHealth] Failed to read telemetry preference from storage.",
+ error,
+ );
+ return false;
+ }
+}
+
+function writeTelemetryOptIn(nextValue) {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ window.localStorage.setItem(
+ TELEMETRY_STORAGE_KEY,
+ nextValue ? "true" : "false",
+ );
+ } catch (error) {
+ userLogger.warn(
+ "[relayHealth] Failed to persist telemetry preference.",
+ error,
+ );
+ }
+}
+
+class RelayHealthService {
+ constructor({ relayManager, nostrClient, logger, telemetryEmitter } = {}) {
+ this.relayManager = relayManager || null;
+ this.nostrClient = nostrClient || null;
+ this.logger = resolveLogger(logger);
+ this.telemetryEmitter =
+ typeof telemetryEmitter === "function" ? telemetryEmitter : null;
+ this.relayStates = new Map();
+ this.attachedRelays = new Set();
+ this.cachedTelemetryOptIn = readTelemetryOptIn();
+ }
+
+ getTelemetryOptIn() {
+ return this.cachedTelemetryOptIn;
+ }
+
+ setTelemetryOptIn(value) {
+ const normalized = Boolean(value);
+ this.cachedTelemetryOptIn = normalized;
+ writeTelemetryOptIn(normalized);
+ return normalized;
+ }
+
+ getRelayUrls() {
+ if (!this.relayManager || typeof this.relayManager.getEntries !== "function") {
+ return [];
+ }
+ const entries = this.relayManager.getEntries();
+ return entries
+ .map((entry) => (typeof entry?.url === "string" ? entry.url.trim() : ""))
+ .filter(Boolean);
+ }
+
+ ensureRelayState(relayUrl) {
+ if (!this.relayStates.has(relayUrl)) {
+ this.relayStates.set(relayUrl, {
+ url: relayUrl,
+ connected: false,
+ lastLatencyMs: null,
+ errorCount: 0,
+ lastCheckedAt: null,
+ lastErrorAt: null,
+ });
+ }
+
+ return this.relayStates.get(relayUrl);
+ }
+
+ attachRelayListeners(relayUrl, relay) {
+ if (!relay || typeof relay.on !== "function") {
+ return;
+ }
+ if (this.attachedRelays.has(relayUrl)) {
+ return;
+ }
+
+ try {
+ relay.on("connect", () => {
+ this.recordRelaySuccess(relayUrl, null);
+ });
+ relay.on("disconnect", () => {
+ this.recordRelayFailure(relayUrl, new Error("relay-disconnect"));
+ });
+ relay.on("error", (error) => {
+ this.recordRelayFailure(relayUrl, error);
+ });
+ this.attachedRelays.add(relayUrl);
+ } catch (error) {
+ this.logger.dev.warn(
+ "[relayHealth] Failed to attach relay listeners.",
+ error,
+ );
+ }
+ }
+
+ recordRelaySuccess(relayUrl, latencyMs) {
+ const state = this.ensureRelayState(relayUrl);
+ state.connected = true;
+ state.lastCheckedAt = Date.now();
+ if (Number.isFinite(latencyMs)) {
+ state.lastLatencyMs = Math.max(0, Math.round(latencyMs));
+ }
+ }
+
+ recordRelayFailure(relayUrl, error) {
+ const state = this.ensureRelayState(relayUrl);
+ state.connected = false;
+ state.errorCount += 1;
+ state.lastCheckedAt = Date.now();
+ state.lastErrorAt = Date.now();
+
+ this.logger.dev.warn("[relayHealth] Relay check failed.", {
+ relayUrl,
+ error,
+ });
+ }
+
+ async checkRelay(relayUrl) {
+ if (!relayUrl) {
+ return;
+ }
+
+ if (!this.nostrClient) {
+ this.recordRelayFailure(relayUrl, new Error("nostr-client-unavailable"));
+ return;
+ }
+
+ try {
+ if (typeof this.nostrClient.ensurePool === "function") {
+ await this.nostrClient.ensurePool();
+ }
+ } catch (error) {
+ this.recordRelayFailure(relayUrl, error);
+ return;
+ }
+
+ if (!this.nostrClient.pool || typeof this.nostrClient.pool.ensureRelay !== "function") {
+ this.recordRelayFailure(relayUrl, new Error("relay-pool-unavailable"));
+ return;
+ }
+
+ const start = nowMs();
+ try {
+ const relay = await this.nostrClient.pool.ensureRelay(relayUrl);
+ if (!relay) {
+ throw new Error("relay-unavailable");
+ }
+
+ this.attachRelayListeners(relayUrl, relay);
+ this.recordRelaySuccess(relayUrl, nowMs() - start);
+ } catch (error) {
+ this.recordRelayFailure(relayUrl, error);
+ }
+ }
+
+ getSnapshot(relayUrls = null) {
+ const urls = Array.isArray(relayUrls) ? relayUrls : this.getRelayUrls();
+ return urls.map((url) => ({ ...this.ensureRelayState(url) }));
+ }
+
+ async refresh(relayUrls = null) {
+ const urls = Array.isArray(relayUrls) ? relayUrls : this.getRelayUrls();
+ if (!urls.length) {
+ return [];
+ }
+
+ await Promise.allSettled(urls.map((url) => this.checkRelay(url)));
+ const snapshot = this.getSnapshot(urls);
+ this.emitTelemetry(snapshot);
+ return snapshot;
+ }
+
+ emitTelemetry(snapshot) {
+ if (!this.telemetryEmitter || !this.getTelemetryOptIn()) {
+ return;
+ }
+
+ const payload = {
+ sampledAt: Date.now(),
+ relays: Array.isArray(snapshot)
+ ? snapshot.map((entry) => ({
+ url: entry.url,
+ connected: Boolean(entry.connected),
+ latencyMs:
+ Number.isFinite(entry.lastLatencyMs) ? entry.lastLatencyMs : null,
+ errorCount: Number.isFinite(entry.errorCount)
+ ? entry.errorCount
+ : 0,
+ }))
+ : [],
+ };
+
+ try {
+ this.telemetryEmitter("relay_health_snapshot", payload);
+ } catch (error) {
+ this.logger.user.warn(
+ "[relayHealth] Failed to emit relay health telemetry.",
+ error,
+ );
+ }
+ }
+}
+
+export default RelayHealthService;
diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js
index 948be196..9d21d8f3 100644
--- a/js/ui/applicationBootstrap.js
+++ b/js/ui/applicationBootstrap.js
@@ -17,6 +17,7 @@ import nostrService from "../services/nostrService.js";
import storageService from "../services/storageService.js";
import watchHistoryService from "../watchHistoryService.js";
import r2Service from "../services/r2Service.js";
+import RelayHealthService from "../services/relayHealthService.js";
import { createFeedEngine } from "../feedEngine/index.js";
import { URL_FIRST_ENABLED } from "../constants.js";
import { ALLOW_NSFW_CONTENT } from "../config.js";
@@ -426,6 +427,12 @@ export default class ApplicationBootstrap {
const modalContainer = doc?.getElementById("modalContainer") || null;
try {
if (modalContainer) {
+ const relayHealthService = new RelayHealthService({
+ relayManager,
+ nostrClient,
+ telemetryEmitter: (eventName, payload) =>
+ app.emitTelemetryEvent(eventName, payload),
+ });
const profileModalServices = {
normalizeHexPubkey: (value) => app.normalizeHexPubkey(value),
safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey),
@@ -480,6 +487,7 @@ export default class ApplicationBootstrap {
app.describeHashtagPreferencesError(error, {
fallbackMessage,
}),
+ relayHealthService,
log: (...args) => app.log(...args),
closeAllMoreMenus: (options) => app.closeAllMoreMenus(options),
clipboard:
diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js
index 50b7eaa7..dd37ca75 100644
--- a/js/ui/profileModalController.js
+++ b/js/ui/profileModalController.js
@@ -1241,6 +1241,12 @@ export class ProfileModalController {
this.relayInput = null;
this.addRelayButton = null;
this.restoreRelaysButton = null;
+ this.relayHealthPanel = null;
+ this.relayHealthList = null;
+ this.relayHealthStatus = null;
+ this.relayHealthRefreshButton = null;
+ this.relayHealthTelemetryToggle = null;
+ this.relayHealthRefreshPromise = null;
this.profileRelayList = null;
this.profileRelayInput = null;
this.profileAddRelayBtn = null;
@@ -1521,6 +1527,16 @@ export class ProfileModalController {
this.addRelayButton = document.getElementById("addRelayBtn") || null;
this.restoreRelaysButton =
document.getElementById("restoreRelaysBtn") || null;
+ this.relayHealthPanel =
+ document.getElementById("relayHealthPanel") || null;
+ this.relayHealthList =
+ document.getElementById("relayHealthList") || null;
+ this.relayHealthStatus =
+ document.getElementById("relayHealthStatus") || null;
+ this.relayHealthRefreshButton =
+ document.getElementById("relayHealthRefreshBtn") || null;
+ this.relayHealthTelemetryToggle =
+ document.getElementById("relayHealthTelemetryOptIn") || null;
this.subscriptionList =
document.getElementById("subscriptionsList") || null;
@@ -4188,6 +4204,18 @@ export class ProfileModalController {
});
}
+ if (this.relayHealthRefreshButton instanceof HTMLElement) {
+ this.relayHealthRefreshButton.addEventListener("click", () => {
+ void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "manual" });
+ });
+ }
+
+ if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) {
+ this.relayHealthTelemetryToggle.addEventListener("change", () => {
+ this.handleRelayHealthTelemetryToggle();
+ });
+ }
+
if (this.addBlockedButton instanceof HTMLElement) {
this.addBlockedButton.addEventListener("click", () => {
void this.handleAddBlockedCreator();
@@ -5828,6 +5856,9 @@ export class ProfileModalController {
if (target === "history") {
void this.populateProfileWatchHistory();
+ } else if (target === "relays") {
+ this.populateProfileRelays();
+ void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "pane-select" });
} else if (target === "messages") {
this.resumeProfileMessages();
void this.populateProfileMessages({ reason: "pane-select" });
@@ -5949,6 +5980,166 @@ export class ProfileModalController {
});
}
+ updateRelayHealthStatus(message = "") {
+ if (!this.relayHealthStatus) {
+ return;
+ }
+
+ const text = typeof message === "string" ? message.trim() : "";
+ this.relayHealthStatus.textContent = text;
+ }
+
+ renderRelayHealthSnapshot(snapshot = []) {
+ if (!this.relayHealthList) {
+ return;
+ }
+
+ this.relayHealthList.innerHTML = "";
+
+ if (!snapshot.length) {
+ const emptyState = document.createElement("li");
+ emptyState.className =
+ "card border border-dashed border-surface-strong p-4 text-center text-sm text-muted";
+ emptyState.textContent = "No relays configured.";
+ this.relayHealthList.appendChild(emptyState);
+ return;
+ }
+
+ snapshot.forEach((entry) => {
+ const item = document.createElement("li");
+ item.className = "card flex flex-col gap-2 p-4";
+
+ const header = document.createElement("div");
+ header.className = "flex items-center justify-between gap-3";
+
+ const urlEl = document.createElement("p");
+ urlEl.className = "text-sm font-medium text-primary break-all";
+ urlEl.textContent = entry.url;
+
+ const connection = document.createElement("span");
+ const connected = Boolean(entry.connected);
+ connection.className = `text-xs font-semibold ${
+ connected ? "text-status-success" : "text-status-danger"
+ }`;
+ connection.textContent = connected ? "Connected" : "Unavailable";
+
+ header.appendChild(urlEl);
+ header.appendChild(connection);
+
+ const details = document.createElement("div");
+ details.className = "grid grid-cols-2 gap-3 text-xs text-muted sm:grid-cols-3";
+
+ const latency = document.createElement("div");
+ latency.className = "flex items-center justify-between gap-2";
+ const latencyLabel = document.createElement("span");
+ latencyLabel.textContent = "Latency";
+ const latencyValue = document.createElement("span");
+ latencyValue.className = "text-text";
+ latencyValue.textContent = Number.isFinite(entry.lastLatencyMs)
+ ? `${entry.lastLatencyMs} ms`
+ : "—";
+ latency.appendChild(latencyLabel);
+ latency.appendChild(latencyValue);
+
+ const errors = document.createElement("div");
+ errors.className = "flex items-center justify-between gap-2";
+ const errorsLabel = document.createElement("span");
+ errorsLabel.textContent = "Errors";
+ const errorsValue = document.createElement("span");
+ errorsValue.className = "text-text";
+ errorsValue.textContent = Number.isFinite(entry.errorCount)
+ ? `${entry.errorCount}`
+ : "0";
+ errors.appendChild(errorsLabel);
+ errors.appendChild(errorsValue);
+
+ const checks = document.createElement("div");
+ checks.className = "flex items-center justify-between gap-2";
+ const checksLabel = document.createElement("span");
+ checksLabel.textContent = "Checked";
+ const checksValue = document.createElement("span");
+ checksValue.className = "text-text";
+ if (Number.isFinite(entry.lastCheckedAt) && entry.lastCheckedAt > 0) {
+ checksValue.textContent = new Date(entry.lastCheckedAt).toLocaleTimeString();
+ } else {
+ checksValue.textContent = "—";
+ }
+ checks.appendChild(checksLabel);
+ checks.appendChild(checksValue);
+
+ details.appendChild(latency);
+ details.appendChild(errors);
+ details.appendChild(checks);
+
+ item.appendChild(header);
+ item.appendChild(details);
+
+ this.relayHealthList.appendChild(item);
+ });
+ }
+
+ handleRelayHealthTelemetryToggle() {
+ const service = this.services?.relayHealthService;
+ if (!service || !(this.relayHealthTelemetryToggle instanceof HTMLInputElement)) {
+ return;
+ }
+
+ const enabled = service.setTelemetryOptIn(
+ this.relayHealthTelemetryToggle.checked,
+ );
+ this.relayHealthTelemetryToggle.checked = enabled;
+ this.updateRelayHealthStatus(
+ enabled ? "Relay health telemetry enabled." : "Relay health telemetry disabled.",
+ );
+ }
+
+ async refreshRelayHealthPanel({ forceRefresh = false, reason = "" } = {}) {
+ const service = this.services?.relayHealthService;
+ if (!service || !this.relayHealthList) {
+ return [];
+ }
+
+ if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) {
+ this.relayHealthTelemetryToggle.checked = service.getTelemetryOptIn();
+ }
+
+ const snapshot = service.getSnapshot();
+ this.renderRelayHealthSnapshot(snapshot);
+
+ if (!forceRefresh) {
+ return snapshot;
+ }
+
+ if (this.relayHealthRefreshPromise) {
+ return this.relayHealthRefreshPromise;
+ }
+
+ const statusMessage = reason === "manual" ? "Refreshing relay health…" : "Checking relays…";
+ this.updateRelayHealthStatus(statusMessage);
+
+ const refreshPromise = service
+ .refresh()
+ .then((latest) => {
+ this.renderRelayHealthSnapshot(latest);
+ this.updateRelayHealthStatus("Relay health updated.");
+ return latest;
+ })
+ .catch((error) => {
+ this.updateRelayHealthStatus("Failed to refresh relay health.");
+ this.showError("Failed to refresh relay health.");
+ devLogger.warn("[profileModal] Relay health refresh failed:", error);
+ return [];
+ })
+ .finally(() => {
+ if (this.relayHealthRefreshPromise === refreshPromise) {
+ this.relayHealthRefreshPromise = null;
+ }
+ });
+
+ this.relayHealthRefreshPromise = refreshPromise;
+ return refreshPromise;
+ }
+
async handleRelayOperation(meta = {}, {
successMessage = "Relay preferences updated.",
skipPublishIfUnchanged = true,
@@ -6010,10 +6201,12 @@ export class ProfileModalController {
this.showSuccess(unchangedMessage);
}
this.populateProfileRelays();
+ void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" });
return operationContext;
}
this.populateProfileRelays();
+ void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" });
if (operationContext.ok) {
if (successMessage) {