diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 7b295cf5..28fbcad5 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -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', ]` 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', ]` 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 tag (`['a', ...]` or `['e', ...]`), optional dedupe `['d', ]`, 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', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, `['head','1']` + `['a',
]` 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[] }` | diff --git a/js/app.js b/js/app.js index e2226a4d..31b3e0f0 100644 --- a/js/app.js +++ b/js/app.js @@ -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"); diff --git a/js/nostr.js b/js/nostr.js index 95da8ecc..ad7179c8 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -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. */ diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 360754d1..b8fbb4de 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -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, diff --git a/js/relayManager.js b/js/relayManager.js new file mode 100644 index 00000000..43e93596 --- /dev/null +++ b/js/relayManager.js @@ -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); +}