Add relay list management for profile relays

This commit is contained in:
thePR0M3TH3AN committed 2025-10-01 14:13:11 -04:00
1 parent 9e509bb782
commit 9f2e06744c
5 files changed
+895 -22

No files matched your search

+5
View File
@@ -37,12 +37,17 @@ const event = buildVideoPostEvent({
In the browser you can call the same helpers from DevTools via
`window.BitVidNostrEvents` and `window.BitVidNostrEventOverrides`.
When managing relay metadata, use `buildRelayListEvent` so the resulting
replaceable event follows NIP-65 (`kind:10002`) with `"r"` tags describing the
read/write split.
## Event catalogue
| Note | Kind (default) | Tags | Content format |
| --- | --- | --- | --- |
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) |
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
| Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content |
| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30078`) | `['t','view']`, `['video', <pointer id>]`, pointer tag (`['a', ...]` or `['e', ...]`), optional dedupe `['d', <scope>]`, optional `['session','true']` when a session actor signs, plus any extra debugging tags | Optional plaintext message |
| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` | `['d', <chunk identifier>]`, `['encrypted','nip04']`, `['snapshot', <id>]`, `['chunk', <index>, <total>]`, pointer tags for each entry, `['head','1']` + `['a', <address>]` pointers on the first chunk | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) |
| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` |
+206 -18
View File
@@ -18,6 +18,7 @@ import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js";
import { userBlocks } from "./userBlocks.js";
import { relayManager } from "./relayManager.js";
import {
loadR2Settings,
saveR2Settings,
@@ -4289,7 +4290,7 @@ class bitvidApp {
if (this.profileAddRelayBtn && !this.profileAddRelayBtn.dataset.bound) {
this.profileAddRelayBtn.dataset.bound = "true";
this.profileAddRelayBtn.addEventListener("click", () => {
this.showSuccess("Relay management coming soon.");
this.handleAddRelay();
});
}
if (
@@ -4298,7 +4299,19 @@ class bitvidApp {
) {
this.profileRestoreRelaysBtn.dataset.bound = "true";
this.profileRestoreRelaysBtn.addEventListener("click", () => {
this.showSuccess("Relay management coming soon.");
this.handleRestoreRelays();
});
}
if (
this.profileRelayInput &&
this.profileRelayInput.dataset.bound !== "true"
) {
this.profileRelayInput.dataset.bound = "true";
this.profileRelayInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleAddRelay();
}
});
}
@@ -4647,21 +4660,40 @@ class bitvidApp {
this.lastFocusedBeforeProfileModal = null;
}
populateProfileRelays(relayUrls) {
populateProfileRelays(relayEntries = null) {
if (!this.profileRelayList) {
return;
}
const rawRelays =
Array.isArray(relayUrls)
? relayUrls
: Array.isArray(nostrClient.relays)
? nostrClient.relays
: Array.from(nostrClient.relays || []);
const sourceEntries = Array.isArray(relayEntries)
? relayEntries
: relayManager.getEntries();
const relays = rawRelays
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
.filter((relay) => relay.length > 0);
const relays = sourceEntries
.map((entry) => {
if (typeof entry === "string") {
const trimmed = entry.trim();
return trimmed ? { url: trimmed, mode: "both" } : null;
}
if (entry && typeof entry === "object") {
const url =
typeof entry.url === "string" ? entry.url.trim() : "";
if (!url) {
return null;
}
const mode = typeof entry.mode === "string" ? entry.mode : "both";
const normalizedMode =
mode === "read" || mode === "write" ? mode : "both";
return {
url,
mode: normalizedMode,
read: entry.read !== false,
write: entry.write !== false,
};
}
return null;
})
.filter((entry) => entry && typeof entry.url === "string");
this.profileRelayList.innerHTML = "";
@@ -4674,7 +4706,7 @@ class bitvidApp {
return;
}
relays.forEach((relayUrl) => {
relays.forEach((entry) => {
const item = document.createElement("li");
item.className =
"flex items-start justify-between gap-4 rounded-lg bg-gray-800 px-4 py-3";
@@ -4684,11 +4716,17 @@ class bitvidApp {
const urlEl = document.createElement("p");
urlEl.className = "text-sm font-medium text-gray-100 break-all";
urlEl.textContent = relayUrl;
urlEl.textContent = entry.url;
const statusEl = document.createElement("p");
statusEl.className = "mt-1 text-xs text-gray-400";
statusEl.textContent = "Active";
let modeLabel = "Read & write";
if (entry.mode === "read") {
modeLabel = "Read only";
} else if (entry.mode === "write") {
modeLabel = "Write only";
}
statusEl.textContent = modeLabel;
info.appendChild(urlEl);
info.appendChild(statusEl);
@@ -4700,9 +4738,10 @@ class bitvidApp {
editBtn.type = "button";
editBtn.className =
"px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
editBtn.textContent = "Edit";
editBtn.textContent = "Change mode";
editBtn.title = "Cycle between read-only, write-only, or read/write modes.";
editBtn.addEventListener("click", () => {
this.showSuccess("Relay management coming soon.");
this.handleRelayModeToggle(entry.url);
});
const removeBtn = document.createElement("button");
@@ -4711,7 +4750,7 @@ class bitvidApp {
"px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
removeBtn.textContent = "Remove";
removeBtn.addEventListener("click", () => {
this.showSuccess("Relay management coming soon.");
this.handleRemoveRelay(entry.url);
});
actions.appendChild(editBtn);
@@ -4724,6 +4763,144 @@ class bitvidApp {
});
}
async handleRelayOperation(operation, {
successMessage = "Relay preferences updated.",
skipPublishIfUnchanged = true,
unchangedMessage = null,
} = {}) {
if (!this.pubkey) {
this.showError("Please login to manage your relays.");
return;
}
if (typeof operation !== "function") {
return;
}
const previous = relayManager.snapshot();
let result;
try {
result = operation();
} catch (error) {
const message =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: "Failed to update relay preferences.";
this.showError(message);
return;
}
const changed = !!result?.changed;
if (!changed && skipPublishIfUnchanged) {
if (result?.reason === "duplicate") {
this.showSuccess("Relay is already configured.");
} else if (typeof unchangedMessage === "string" && unchangedMessage) {
this.showSuccess(unchangedMessage);
}
this.populateProfileRelays();
return;
}
this.populateProfileRelays();
try {
const publishResult = await relayManager.publishRelayList(this.pubkey);
if (!publishResult?.ok) {
throw new Error("No relays accepted the update.");
}
if (successMessage) {
this.showSuccess(successMessage);
}
} catch (error) {
relayManager.setEntries(previous, { allowEmpty: false });
this.populateProfileRelays();
const message =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: "Failed to publish relay configuration. Please try again.";
this.showError(message);
}
}
async handleAddRelay() {
if (!this.pubkey) {
this.showError("Please login to manage your relays.");
return;
}
const rawValue =
typeof this.profileRelayInput?.value === "string"
? this.profileRelayInput.value
: "";
const trimmed = rawValue.trim();
if (!trimmed) {
this.showError("Enter a relay URL to add.");
return;
}
await this.handleRelayOperation(
() => relayManager.addRelay(trimmed),
{
successMessage: "Relay saved.",
unchangedMessage: "Relay is already configured.",
}
);
if (this.profileRelayInput) {
this.profileRelayInput.value = "";
}
}
async handleRestoreRelays() {
if (!this.pubkey) {
this.showError("Please login to manage your relays.");
return;
}
const confirmed = window.confirm(
"Restore the recommended relay defaults?"
);
if (!confirmed) {
return;
}
await this.handleRelayOperation(
() => relayManager.restoreDefaults(),
{
successMessage: "Relay defaults restored.",
unchangedMessage: "Relay defaults are already in use.",
}
);
}
async handleRelayModeToggle(url) {
if (!url) {
return;
}
await this.handleRelayOperation(
() => relayManager.cycleRelayMode(url),
{ successMessage: "Relay mode updated." }
);
}
async handleRemoveRelay(url) {
if (!url) {
return;
}
const confirmed = window.confirm(
`Remove ${url} from your relay list?`
);
if (!confirmed) {
return;
}
await this.handleRelayOperation(
() => relayManager.removeRelay(url),
{ successMessage: "Relay removed." }
);
}
populateBlockedList(blocked = null) {
if (!this.profileBlockedList || !this.profileBlockedEmpty) {
return;
@@ -6402,6 +6579,14 @@ class bitvidApp {
this.populateBlockedList();
try {
await relayManager.loadRelayList(this.pubkey);
} catch (error) {
console.warn("Failed to load relay list:", error);
}
this.populateProfileRelays();
if (this.loginButton) {
this.loginButton.classList.add("hidden");
this.loginButton.setAttribute("hidden", "");
@@ -6581,6 +6766,9 @@ class bitvidApp {
userBlocks.reset();
this.populateBlockedList();
relayManager.reset();
this.populateProfileRelays();
// Show the login button again
if (this.loginButton) {
this.loginButton.classList.remove("hidden");
+67 -4
View File
@@ -24,15 +24,18 @@ import {
} from "./nostrEventSchemas.js";
/**
* The usual relays
* The default relay set BitVid bootstraps with before loading a user's
* preferences.
*/
const RELAY_URLS = [
export const DEFAULT_RELAY_URLS = Object.freeze([
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://relay.primal.net",
"wss://relay.nostr.band",
];
]);
const RELAY_URLS = Array.from(DEFAULT_RELAY_URLS);
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
@@ -140,6 +143,35 @@ function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request ti
});
}
function sanitizeRelayList(list) {
const seen = new Set();
const sanitized = [];
if (!Array.isArray(list)) {
return sanitized;
}
list.forEach((value) => {
if (typeof value !== "string") {
return;
}
const trimmed = value.trim();
if (!trimmed) {
return;
}
if (!/^wss?:\/\//i.test(trimmed)) {
return;
}
const normalized = trimmed.replace(/\/+$/, "");
if (seen.has(normalized)) {
return;
}
seen.add(normalized);
sanitized.push(normalized);
});
return sanitized;
}
function pointerKey(pointer) {
if (!pointer || typeof pointer !== "object") {
return "";
@@ -1098,7 +1130,9 @@ class NostrClient {
constructor() {
this.pool = null;
this.pubkey = null;
this.relays = RELAY_URLS;
this.relays = sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
this.readRelays = Array.from(this.relays);
this.writeRelays = Array.from(this.relays);
// Store all events so older links still work
this.allEvents = new Map();
@@ -1215,6 +1249,35 @@ class NostrClient {
return this.allEvents.size > 0;
}
applyRelayPreferences(preferences = {}) {
const normalizedPrefs =
preferences && typeof preferences === "object" ? preferences : {};
const sanitizedAll = sanitizeRelayList(
Array.isArray(normalizedPrefs.all)
? normalizedPrefs.all
: this.relays
);
const effectiveAll =
sanitizedAll.length > 0
? sanitizedAll
: sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
const sanitizedRead = sanitizeRelayList(
Array.isArray(normalizedPrefs.read)
? normalizedPrefs.read
: effectiveAll
);
const sanitizedWrite = sanitizeRelayList(
Array.isArray(normalizedPrefs.write)
? normalizedPrefs.write
: effectiveAll
);
this.relays = effectiveAll.length ? effectiveAll : Array.from(RELAY_URLS);
this.readRelays = sanitizedRead.length ? sanitizedRead : Array.from(this.relays);
this.writeRelays = sanitizedWrite.length ? sanitizedWrite : Array.from(this.relays);
}
/**
* Resolve the effective TTL for watch-history cache entries.
*/
+84
View File
@@ -8,6 +8,7 @@ import {
export const NOTE_TYPES = Object.freeze({
VIDEO_POST: "videoPost",
VIDEO_MIRROR: "videoMirror",
RELAY_LIST: "relayList",
VIEW_EVENT: "viewEvent",
WATCH_HISTORY_CHUNK: "watchHistoryChunk",
SUBSCRIPTION_LIST: "subscriptionList",
@@ -66,6 +67,16 @@ const BASE_SCHEMAS = {
description: "Optional alt text carried alongside hosted URL metadata.",
},
},
[NOTE_TYPES.RELAY_LIST]: {
type: NOTE_TYPES.RELAY_LIST,
label: "Relay list metadata",
kind: 10002,
relayTagName: "r",
readMarker: "read",
writeMarker: "write",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.VIEW_EVENT]: {
type: NOTE_TYPES.VIEW_EVENT,
label: "View counter",
@@ -311,6 +322,79 @@ export function buildVideoMirrorEvent({
};
}
export function buildRelayListEvent({
pubkey,
created_at,
relays = [],
additionalTags = [],
}) {
const schema = getNostrEventSchema(NOTE_TYPES.RELAY_LIST);
const tags = [];
const relayTagName = schema?.relayTagName || "r";
const readMarker = schema?.readMarker || "read";
const writeMarker = schema?.writeMarker || "write";
if (Array.isArray(relays)) {
relays.forEach((entry) => {
let url = "";
let mode = "";
if (typeof entry === "string") {
url = entry;
} else if (entry && typeof entry === "object") {
if (typeof entry.url === "string") {
url = entry.url;
}
if (typeof entry.mode === "string") {
mode = entry.mode;
} else if (typeof entry.marker === "string") {
mode = entry.marker;
} else if (entry.read === true && entry.write === false) {
mode = "read";
} else if (entry.write === true && entry.read === false) {
mode = "write";
}
} else if (Array.isArray(entry) && entry.length >= 1) {
url = typeof entry[0] === "string" ? entry[0] : String(entry[0]);
if (entry.length > 1 && typeof entry[1] === "string") {
mode = entry[1];
}
}
const normalizedUrl = typeof url === "string" ? url.trim() : "";
if (!normalizedUrl) {
return;
}
const normalizedMode = typeof mode === "string" ? mode.trim().toLowerCase() : "";
if (normalizedMode === "read") {
tags.push([relayTagName, normalizedUrl, readMarker]);
} else if (normalizedMode === "write") {
tags.push([relayTagName, normalizedUrl, writeMarker]);
} else {
tags.push([relayTagName, normalizedUrl]);
}
});
}
appendSchemaTags(tags, schema);
if (Array.isArray(additionalTags)) {
additionalTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
return {
kind: schema?.kind ?? 10002,
pubkey,
created_at,
tags,
content: "",
};
}
export function buildViewEvent({
pubkey,
created_at,
+533
View File
@@ -0,0 +1,533 @@
import { isDevMode } from "./config.js";
import { DEFAULT_RELAY_URLS, nostrClient } from "./nostr.js";
import { buildRelayListEvent } from "./nostrEventSchemas.js";
const MODE_SEQUENCE = ["both", "read", "write"];
function normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (!/^[0-9a-f]{64}$/i.test(trimmed)) {
return null;
}
return trimmed.toLowerCase();
}
function normalizeRelayUrl(input) {
if (typeof input !== "string") {
return null;
}
let candidate = input.trim();
if (!candidate) {
return null;
}
if (!/^[a-z]+:\/\//i.test(candidate)) {
candidate = `wss://${candidate}`;
}
let parsed;
try {
parsed = new URL(candidate);
} catch (error) {
return null;
}
if (parsed.protocol !== "wss:" && parsed.protocol !== "ws:") {
return null;
}
const hostname = parsed.hostname.toLowerCase();
const port = parsed.port ? `:${parsed.port}` : "";
let pathname = parsed.pathname || "";
if (pathname !== "/") {
pathname = pathname.replace(/\/+$/, "");
}
if (pathname === "/") {
pathname = "";
}
const search = parsed.search || "";
const hash = parsed.hash || "";
return `${parsed.protocol}//${hostname}${port}${pathname}${search}${hash}`;
}
function deriveMode(marker) {
if (typeof marker !== "string") {
return "both";
}
const normalized = marker.trim().toLowerCase();
if (normalized === "read") {
return "read";
}
if (normalized === "write") {
return "write";
}
return "both";
}
function createEntry(url, mode = "both") {
const normalizedMode = deriveMode(mode);
return {
url,
mode: normalizedMode,
read: normalizedMode !== "write",
write: normalizedMode !== "read",
};
}
function cloneEntry(entry) {
return {
url: entry.url,
mode: entry.mode,
read: !!entry.read,
write: !!entry.write,
};
}
function serializeEntries(entries) {
return JSON.stringify(
entries.map((entry) => ({ url: entry.url, mode: entry.mode }))
);
}
function parseRelayTags(tags) {
if (!Array.isArray(tags)) {
return [];
}
const seen = new Map();
const order = [];
tags.forEach((tag) => {
if (!Array.isArray(tag) || tag.length < 2) {
return;
}
if (tag[0] !== "r") {
return;
}
const normalizedUrl = normalizeRelayUrl(tag[1]);
if (!normalizedUrl) {
return;
}
if (!seen.has(normalizedUrl)) {
seen.set(normalizedUrl, { read: false, write: false });
order.push(normalizedUrl);
}
const record = seen.get(normalizedUrl);
const mode = deriveMode(tag[2]);
if (mode === "read") {
record.read = true;
} else if (mode === "write") {
record.write = true;
} else {
record.read = true;
record.write = true;
}
});
return order.map((url) => {
const record = seen.get(url) || { read: true, write: true };
if (record.read && record.write) {
return createEntry(url, "both");
}
if (record.read) {
return createEntry(url, "read");
}
if (record.write) {
return createEntry(url, "write");
}
return createEntry(url, "both");
});
}
function resolveEntryInput(entry) {
if (typeof entry === "string") {
return { url: entry, mode: "both" };
}
if (!entry || typeof entry !== "object") {
return null;
}
const url = typeof entry.url === "string" ? entry.url : "";
let mode = entry.mode || entry.marker;
if (!mode) {
if (entry.read === true && entry.write === false) {
mode = "read";
} else if (entry.write === true && entry.read === false) {
mode = "write";
}
}
return { url, mode: deriveMode(mode) };
}
class RelayPreferencesManager {
constructor() {
this.entries = [];
this.entryIndex = new Map();
this.lastEvent = null;
this.loadedPubkey = null;
this.lastLoadSource = "default";
this.defaultEntries = DEFAULT_RELAY_URLS.map((url) => {
const normalized = normalizeRelayUrl(url) || url;
return createEntry(normalized, "both");
});
this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true });
}
snapshot() {
return this.getEntries();
}
getEntries() {
return this.entries.map((entry) => cloneEntry(entry));
}
getAllRelayUrls() {
return this.entries.map((entry) => entry.url);
}
getReadRelayUrls() {
return this.entries.filter((entry) => entry.read).map((entry) => entry.url);
}
getWriteRelayUrls() {
return this.entries.filter((entry) => entry.write).map((entry) => entry.url);
}
syncClient() {
if (nostrClient && typeof nostrClient.applyRelayPreferences === "function") {
nostrClient.applyRelayPreferences({
all: this.getAllRelayUrls(),
read: this.getReadRelayUrls(),
write: this.getWriteRelayUrls(),
});
} else if (nostrClient) {
nostrClient.relays = this.getAllRelayUrls();
}
}
setEntries(entries, { allowEmpty = false, updateClient = true } = {}) {
const nextEntries = [];
const indexMap = new Map();
if (Array.isArray(entries)) {
entries.forEach((item) => {
const resolved = resolveEntryInput(item);
if (!resolved) {
return;
}
const normalizedUrl = normalizeRelayUrl(resolved.url);
if (!normalizedUrl || indexMap.has(normalizedUrl)) {
return;
}
const normalizedEntry = createEntry(normalizedUrl, resolved.mode);
indexMap.set(normalizedUrl, nextEntries.length);
nextEntries.push(normalizedEntry);
});
}
if (!nextEntries.length && !allowEmpty) {
this.entries = this.defaultEntries.map((entry) => cloneEntry(entry));
this.entryIndex = new Map(
this.entries.map((entry, idx) => [entry.url, idx])
);
if (updateClient) {
this.syncClient();
}
return this.getEntries();
}
this.entries = nextEntries.map((entry) => cloneEntry(entry));
this.entryIndex = new Map(
this.entries.map((entry, idx) => [entry.url, idx])
);
if (!this.entries.length && !allowEmpty) {
this.entries = this.defaultEntries.map((entry) => cloneEntry(entry));
this.entryIndex = new Map(
this.entries.map((entry, idx) => [entry.url, idx])
);
}
if (updateClient) {
this.syncClient();
}
return this.getEntries();
}
addRelay(url, mode = "both") {
const normalizedUrl = normalizeRelayUrl(url);
if (!normalizedUrl) {
const error = new Error("Enter a valid WSS relay URL.");
error.code = "invalid";
throw error;
}
if (this.entryIndex.has(normalizedUrl)) {
return {
changed: false,
reason: "duplicate",
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
};
}
const nextEntries = this.getEntries();
nextEntries.push({ url: normalizedUrl, mode });
this.setEntries(nextEntries, { allowEmpty: false });
return {
changed: true,
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
};
}
updateRelayMode(url, mode = "both") {
const normalizedUrl = normalizeRelayUrl(url);
if (!normalizedUrl) {
const error = new Error("Enter a valid relay URL.");
error.code = "invalid";
throw error;
}
if (!this.entryIndex.has(normalizedUrl)) {
return { changed: false, reason: "missing" };
}
const normalizedMode = deriveMode(mode);
const index = this.entryIndex.get(normalizedUrl);
const current = this.entries[index];
if (current.mode === normalizedMode) {
return { changed: false, entry: cloneEntry(current) };
}
const nextEntries = this.getEntries();
nextEntries[index].mode = normalizedMode;
this.setEntries(nextEntries, { allowEmpty: false });
return {
changed: true,
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
};
}
cycleRelayMode(url) {
const normalizedUrl = normalizeRelayUrl(url);
if (!normalizedUrl) {
const error = new Error("Enter a valid relay URL.");
error.code = "invalid";
throw error;
}
if (!this.entryIndex.has(normalizedUrl)) {
const error = new Error("Relay not found.");
error.code = "missing";
throw error;
}
const index = this.entryIndex.get(normalizedUrl);
const currentMode = this.entries[index].mode || "both";
const currentIdx = MODE_SEQUENCE.indexOf(currentMode);
const nextMode = MODE_SEQUENCE[(currentIdx + 1) % MODE_SEQUENCE.length];
return this.updateRelayMode(normalizedUrl, nextMode);
}
removeRelay(url) {
const normalizedUrl = normalizeRelayUrl(url);
if (!normalizedUrl) {
const error = new Error("Enter a valid relay URL to remove.");
error.code = "invalid";
throw error;
}
if (!this.entryIndex.has(normalizedUrl)) {
return { changed: false, reason: "missing" };
}
if (this.entries.length <= 1) {
const error = new Error("At least one relay is required.");
error.code = "minimum";
throw error;
}
const nextEntries = this.getEntries().filter((entry) => entry.url !== normalizedUrl);
this.setEntries(nextEntries, { allowEmpty: false });
return { changed: true };
}
restoreDefaults() {
const before = serializeEntries(this.entries);
this.setEntries(this.defaultEntries, { allowEmpty: false });
const after = serializeEntries(this.entries);
return { changed: before !== after };
}
reset() {
this.lastEvent = null;
this.loadedPubkey = null;
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
}
getPublishTargets(customTargets = null) {
const targets = new Set();
const list =
Array.isArray(customTargets) && customTargets.length
? customTargets
: this.getAllRelayUrls();
list.forEach((candidate) => {
const normalized = normalizeRelayUrl(candidate);
if (normalized) {
targets.add(normalized);
}
});
DEFAULT_RELAY_URLS.forEach((url) => {
const normalized = normalizeRelayUrl(url) || url;
targets.add(normalized);
});
return Array.from(targets);
}
async loadRelayList(pubkey) {
const normalizedPubkey = normalizeHexPubkey(pubkey);
this.loadedPubkey = normalizedPubkey;
if (!normalizedPubkey) {
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: false, reason: "invalid-pubkey" };
}
if (!nostrClient?.pool) {
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: false, reason: "nostr-uninitialized" };
}
const filter = { kinds: [10002], authors: [normalizedPubkey], limit: 1 };
const targetRelays = this.getPublishTargets();
const events = [];
await Promise.all(
targetRelays.map(async (relayUrl) => {
try {
const result = await nostrClient.pool.list([relayUrl], [filter]);
if (Array.isArray(result) && result.length) {
events.push(...result);
}
} catch (error) {
if (isDevMode) {
console.warn(`[relayManager] Failed to fetch relay list from ${relayUrl}:`, error);
}
}
})
);
if (!events.length) {
this.lastEvent = null;
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: true, source: "default", events: [] };
}
events.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
const newest = events[0];
this.lastEvent = newest;
const parsed = parseRelayTags(newest?.tags);
if (parsed.length) {
this.setEntries(parsed, { allowEmpty: false });
this.lastLoadSource = "event";
return { ok: true, source: "event", event: newest };
}
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: true, source: "default", event: newest };
}
async publishRelayList(pubkey, options = {}) {
const normalizedPubkey = normalizeHexPubkey(pubkey);
if (!normalizedPubkey) {
const error = new Error(
"A valid pubkey is required to publish relay preferences."
);
error.code = "invalid-pubkey";
throw error;
}
if (!window?.nostr?.signEvent) {
const error = new Error(
"A NIP-07 extension is required to publish relay preferences."
);
error.code = "nostr-extension-missing";
throw error;
}
if (!nostrClient?.pool) {
const error = new Error(
"Nostr is not connected yet. Please try again once relays are ready."
);
error.code = "nostr-uninitialized";
throw error;
}
const entries = this.getEntries();
if (!entries.length) {
const error = new Error("Add at least one relay before publishing.");
error.code = "empty";
throw error;
}
const event = buildRelayListEvent({
pubkey: normalizedPubkey,
created_at: Math.floor(Date.now() / 1000),
relays: entries,
});
const signedEvent = await window.nostr.signEvent(event);
const targets = this.getPublishTargets(options?.relayUrls);
if (!targets.length) {
const error = new Error("No relay targets are available for publishing.");
error.code = "no-targets";
throw error;
}
const publishResults = await Promise.allSettled(
targets.map((url) => nostrClient.pool.publish([url], signedEvent))
);
const accepted = [];
const failed = [];
publishResults.forEach((result, index) => {
const url = targets[index];
if (result.status === "fulfilled") {
accepted.push(url);
} else {
failed.push({ url, reason: result.reason || null });
}
});
const ok = accepted.length > 0;
if (ok) {
this.lastEvent = signedEvent;
}
return { ok, event: signedEvent, accepted, failed, targets };
}
getLastLoadSource() {
return this.lastLoadSource;
}
}
export const relayManager = new RelayPreferencesManager();
export function normalizeRelayUrlForDisplay(value) {
return normalizeRelayUrl(value);
}