diff --git a/src/components/DecodeViewer.tsx b/src/components/DecodeViewer.tsx
index a2fa191..598d5a2 100644
--- a/src/components/DecodeViewer.tsx
+++ b/src/components/DecodeViewer.tsx
@@ -11,6 +11,7 @@ import { useCopy } from "../hooks/useCopy";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { KindBadge } from "./KindBadge";
+import { normalizeRelayURL } from "@/lib/relay-url";
interface DecodeViewerProps {
args: string[];
@@ -77,7 +78,7 @@ export default function DecodeViewer({ args }: DecodeViewerProps) {
setError("Relay must be a WebSocket URL (ws:// or wss://)");
return;
}
- setRelays([...relays, relayUrl]);
+ setRelays([...relays, normalizeRelayURL(relayUrl)]);
setNewRelay("");
setError(null);
} catch {
diff --git a/src/components/EncodeViewer.tsx b/src/components/EncodeViewer.tsx
index 5af3a1c..eadc9e0 100644
--- a/src/components/EncodeViewer.tsx
+++ b/src/components/EncodeViewer.tsx
@@ -8,6 +8,7 @@ import {
import { useCopy } from "../hooks/useCopy";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
+import { normalizeRelayURL } from "@/lib/relay-url";
interface EncodeViewerProps {
args: string[];
@@ -66,7 +67,7 @@ export default function EncodeViewer({ args }: EncodeViewerProps) {
setError("Relay must be a WebSocket URL (ws:// or wss://)");
return;
}
- setRelays([...relays, relayUrl]);
+ setRelays([...relays, normalizeRelayURL(relayUrl)]);
setNewRelay("");
setError(null);
} catch {
diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx
index 37aef7b..3e1b180 100644
--- a/src/components/ReqViewer.tsx
+++ b/src/components/ReqViewer.tsx
@@ -473,21 +473,24 @@ export default function ReqViewer({
{/* Results */}
- {loading && events.length === 0 && (
+ {/* Loading: Before EOSE received */}
+ {loading && events.length === 0 && !eoseReceived && (
Loading events...
)}
- {!loading && !stream && events.length === 0 && !error && (
+ {/* EOSE received, no events, not streaming */}
+ {eoseReceived && events.length === 0 && !stream && !error && (
No events found matching filter
)}
- {stream && events.length === 0 && !loading && (
+ {/* EOSE received, no events, streaming (live mode) */}
+ {eoseReceived && events.length === 0 && stream && (
- Waiting for events...
+ Listening for new events...
)}
diff --git a/src/components/nostr/RelayLink.tsx b/src/components/nostr/RelayLink.tsx
index df188cd..659303e 100644
--- a/src/components/nostr/RelayLink.tsx
+++ b/src/components/nostr/RelayLink.tsx
@@ -1,4 +1,4 @@
-import { Inbox, Send } from "lucide-react";
+import { Inbox, Send, ShieldAlert } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useRelayInfo } from "@/hooks/useRelayInfo";
import {
@@ -8,6 +8,22 @@ import {
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
+/**
+ * Format relay URL for display by removing protocol and trailing slashes
+ */
+function formatRelayUrlForDisplay(url: string): string {
+ return url
+ .replace(/^wss?:\/\//, "") // Remove ws:// or wss://
+ .replace(/\/$/, ""); // Remove trailing slash
+}
+
+/**
+ * Check if relay uses insecure ws:// protocol
+ */
+function isInsecureRelay(url: string): boolean {
+ return url.startsWith("ws://");
+}
+
export interface RelayLinkProps {
url: string;
read?: boolean;
@@ -46,6 +62,9 @@ export function RelayLink({
prompt: "cursor-crosshair hover:underline hover:decoration-dotted",
};
+ const displayUrl = formatRelayUrlForDisplay(url);
+ const isInsecure = isInsecureRelay(url);
+
return (
)}
-
{url}
+ {isInsecure && (
+
+
+
+
+
+
+ e.stopPropagation()}
+ >
+
+
Insecure Connection
+
+ This relay uses unencrypted ws:// protocol. This is typically
+ only safe for localhost/development. Production relays should
+ use wss:// (secure WebSocket).
+
+
+
+
+ )}
+
+ {displayUrl}
+
{showInboxOutbox && read && (
diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts
index 551bf9c..e6946e3 100644
--- a/src/hooks/useAccountSync.ts
+++ b/src/hooks/useAccountSync.ts
@@ -5,6 +5,7 @@ import { useGrimoire } from "@/core/state";
import { getInboxes, getOutboxes } from "applesauce-core/helpers";
import { addressLoader } from "@/services/loaders";
import type { RelayInfo, UserRelays } from "@/types/app";
+import { normalizeRelayURL } from "@/lib/relay-url";
/**
* Hook that syncs active account with Grimoire state and fetches relay lists
@@ -56,31 +57,46 @@ export function useAccountSync() {
const seenUrls = new Set
();
for (const tag of relayListEvent.tags) {
- if (tag[0] === "r") {
- const url = tag[1];
- if (seenUrls.has(url)) continue;
- seenUrls.add(url);
+ if (tag[0] === "r" && tag[1]) {
+ try {
+ const url = normalizeRelayURL(tag[1]);
+ if (seenUrls.has(url)) continue;
+ seenUrls.add(url);
- const type = tag[2];
- allRelays.push({
- url,
- read: !type || type === "read",
- write: !type || type === "write",
- });
+ const type = tag[2];
+ allRelays.push({
+ url,
+ read: !type || type === "read",
+ write: !type || type === "write",
+ });
+ } catch (error) {
+ console.warn(
+ `Skipping invalid relay URL in Kind 10002 event: ${tag[1]}`,
+ error
+ );
+ }
}
}
const relays: UserRelays = {
- inbox: inboxRelays.map((url) => ({
- url,
- read: true,
- write: false,
- })),
- outbox: outboxRelays.map((url) => ({
- url,
- read: false,
- write: true,
- })),
+ inbox: inboxRelays
+ .map((url) => {
+ try {
+ return { url: normalizeRelayURL(url), read: true, write: false };
+ } catch {
+ return null;
+ }
+ })
+ .filter((r): r is RelayInfo => r !== null),
+ outbox: outboxRelays
+ .map((url) => {
+ try {
+ return { url: normalizeRelayURL(url), read: false, write: true };
+ } catch {
+ return null;
+ }
+ })
+ .filter((r): r is RelayInfo => r !== null),
all: allRelays,
};
diff --git a/src/hooks/useRelayInfo.ts b/src/hooks/useRelayInfo.ts
index e803181..3cd2e58 100644
--- a/src/hooks/useRelayInfo.ts
+++ b/src/hooks/useRelayInfo.ts
@@ -1,8 +1,9 @@
import { useLiveQuery } from "dexie-react-hooks";
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
import { RelayInformation } from "../types/nip11";
import { fetchRelayInfo } from "../lib/nip11";
import db from "../services/db";
+import { normalizeRelayURL } from "../lib/relay-url";
/**
* React hook to fetch and cache relay information (NIP-11)
@@ -12,26 +13,37 @@ import db from "../services/db";
export function useRelayInfo(
wsUrl: string | undefined,
): RelayInformation | undefined {
+ // Normalize URL once
+ const normalizedUrl = useMemo(() => {
+ if (!wsUrl) return undefined;
+ try {
+ return normalizeRelayURL(wsUrl);
+ } catch (error) {
+ console.warn(`useRelayInfo: Invalid relay URL ${wsUrl}:`, error);
+ return undefined;
+ }
+ }, [wsUrl]);
+
const cached = useLiveQuery(
- () => (wsUrl ? db.relayInfo.get(wsUrl) : undefined),
- [wsUrl],
+ () => (normalizedUrl ? db.relayInfo.get(normalizedUrl) : undefined),
+ [normalizedUrl],
);
useEffect(() => {
- if (!wsUrl) return;
+ if (!normalizedUrl) return;
if (cached) return;
// Fetch relay info if not in cache
- fetchRelayInfo(wsUrl).then((info) => {
+ fetchRelayInfo(normalizedUrl).then((info) => {
if (info) {
db.relayInfo.put({
- url: wsUrl,
+ url: normalizedUrl,
info,
fetchedAt: Date.now(),
});
}
});
- }, [cached, wsUrl]);
+ }, [cached, normalizedUrl]);
return cached?.info;
}
diff --git a/src/hooks/useRelayState.ts b/src/hooks/useRelayState.ts
index d014da7..1286889 100644
--- a/src/hooks/useRelayState.ts
+++ b/src/hooks/useRelayState.ts
@@ -3,6 +3,7 @@ import { useAtom } from "jotai";
import { grimoireStateAtom } from "@/core/state";
import relayStateManager from "@/services/relay-state-manager";
import type { AuthPreference, RelayState } from "@/types/relay-state";
+import { normalizeRelayURL } from "@/lib/relay-url";
/**
* Hook for accessing and managing global relay state
@@ -45,7 +46,8 @@ export function useRelayState() {
// Get single relay state
getRelay: (url: string): RelayState | undefined => {
- return relayState?.relays[url];
+ const normalizedUrl = normalizeRelayURL(url);
+ return relayState?.relays[normalizedUrl];
},
// Get auth preference
diff --git a/src/lib/encode-parser.ts b/src/lib/encode-parser.ts
index 299a1a2..7e9b5c2 100644
--- a/src/lib/encode-parser.ts
+++ b/src/lib/encode-parser.ts
@@ -1,5 +1,6 @@
import { nip19 } from "nostr-tools";
import { isValidHexEventId, isValidHexPubkey } from "./nostr-validation";
+import { normalizeRelayURL } from "./relay-url";
export type EncodeType = "npub" | "note" | "nevent" | "nprofile" | "naddr";
@@ -55,7 +56,7 @@ export function parseEncodeCommand(args: string[]): ParsedEncodeCommand {
if (i + 1 >= args.length) {
throw new Error(`${flag} requires a relay URL`);
}
- relays.push(args[i + 1]);
+ relays.push(normalizeRelayURL(args[i + 1]));
i += 2;
continue;
}
diff --git a/src/lib/nip11.ts b/src/lib/nip11.ts
index 43d75fd..7bc6fa6 100644
--- a/src/lib/nip11.ts
+++ b/src/lib/nip11.ts
@@ -1,5 +1,6 @@
import { RelayInformation } from "../types/nip11";
import db from "../services/db";
+import { normalizeRelayURL } from "./relay-url";
/**
* NIP-11: Relay Information Document
@@ -16,8 +17,11 @@ export async function fetchRelayInfo(
wsUrl: string,
): Promise {
try {
+ // Normalize URL for consistency
+ const normalizedUrl = normalizeRelayURL(wsUrl);
+
// Convert ws:// or wss:// to https://
- const httpUrl = wsUrl.replace(/^ws(s)?:/, "https:");
+ const httpUrl = normalizedUrl.replace(/^ws(s)?:/, "https:");
const response = await fetch(httpUrl, {
headers: { Accept: "application/nostr+json" },
@@ -38,17 +42,23 @@ export async function fetchRelayInfo(
export async function getRelayInfo(
wsUrl: string,
): Promise {
- const cached = await db.relayInfo.get(wsUrl);
- const isExpired = !cached || Date.now() - cached.fetchedAt > CACHE_DURATION;
+ try {
+ const normalizedUrl = normalizeRelayURL(wsUrl);
+ const cached = await db.relayInfo.get(normalizedUrl);
+ const isExpired = !cached || Date.now() - cached.fetchedAt > CACHE_DURATION;
- if (!isExpired) return cached.info;
+ if (!isExpired) return cached.info;
- const info = await fetchRelayInfo(wsUrl);
- if (info) {
- await db.relayInfo.put({ url: wsUrl, info, fetchedAt: Date.now() });
+ const info = await fetchRelayInfo(normalizedUrl);
+ if (info) {
+ await db.relayInfo.put({ url: normalizedUrl, info, fetchedAt: Date.now() });
+ }
+
+ return info;
+ } catch (error) {
+ console.warn(`NIP-11: Failed to get relay info for ${wsUrl}:`, error);
+ return null;
}
-
- return info;
}
/**
@@ -57,8 +67,14 @@ export async function getRelayInfo(
export async function getCachedRelayInfo(
wsUrl: string,
): Promise {
- const cached = await db.relayInfo.get(wsUrl);
- return cached?.info ?? null;
+ try {
+ const normalizedUrl = normalizeRelayURL(wsUrl);
+ const cached = await db.relayInfo.get(normalizedUrl);
+ return cached?.info ?? null;
+ } catch (error) {
+ console.warn(`NIP-11: Failed to get cached relay info for ${wsUrl}:`, error);
+ return null;
+ }
}
/**
@@ -68,10 +84,20 @@ export async function getRelayInfoBatch(
wsUrls: string[],
): Promise