mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add relay list management for profile relays
This commit is contained in:
1 parent
9e509bb782
commit
9f2e06744c
5 files changed
+895
-22
No files matched your search
@@ -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[] }` |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user