diff --git a/.github/prompts/nostr-nip65.prompt.md b/.github/prompts/nostr-nip65.prompt.md
new file mode 100644
index 0000000..81b3cf4
--- /dev/null
+++ b/.github/prompts/nostr-nip65.prompt.md
@@ -0,0 +1,42 @@
+NIP-65
+======
+
+Relay List Metadata
+-------------------
+
+`draft` `optional`
+
+Defines a replaceable event using `kind:10002` to advertise relays where the user generally **writes** to and relays where the user generally **reads** mentions.
+
+The event MUST include a list of `r` tags with relay URLs as value and an optional `read` or `write` marker. If the marker is omitted, the relay is both **read** and **write**.
+
+```jsonc
+{
+ "kind": 10002,
+ "tags": [
+ ["r", "wss://alicerelay.example.com"],
+ ["r", "wss://brando-relay.com"],
+ ["r", "wss://expensive-relay.example2.com", "write"],
+ ["r", "wss://nostr-relay.example.com", "read"]
+ ],
+ "content": "",
+ // other fields...
+}
+```
+
+When downloading events **from** a user, clients SHOULD use the **write** relays of that user.
+
+When downloading events **about** a user, where the user was tagged (mentioned), clients SHOULD use the user's **read** relays.
+
+When publishing an event, clients SHOULD:
+
+- Send the event to the **write** relays of the author
+- Send the event to all **read** relays of each tagged user
+
+### Size
+
+Clients SHOULD guide users to keep `kind:10002` lists small (2-4 relays of each category).
+
+### Discoverability
+
+Clients SHOULD spread an author's `kind:10002` event to as many relays as viable, paying attention to relays that, at any moment, serve naturally as well-known public indexers for these relay lists (where most other clients and users are connecting to in order to publish and fetch those).
\ No newline at end of file
diff --git a/app/relays/page.tsx b/app/relays/page.tsx
index f7c7e08..307a3d4 100644
--- a/app/relays/page.tsx
+++ b/app/relays/page.tsx
@@ -4,19 +4,23 @@ import { useNostr } from "nostr-react";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
-import { CheckCircle2, XCircle, AlertCircle, SignalHigh, Clock } from "lucide-react";
+import { CheckCircle2, XCircle, AlertCircle, SignalHigh, Clock, RefreshCw } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { AddRelaySheet } from "@/components/AddRelaySheet";
import { ManageCustomRelays } from "@/components/ManageCustomRelays";
+import { fetchNip65Relays, mergeAndStoreRelays } from "@/utils/nip65Utils";
+import { useToast } from "@/components/ui/use-toast";
export default function RelaysPage() {
const { connectedRelays } = useNostr();
const [relayStatus, setRelayStatus] = useState<{ [url: string]: 'connected' | 'connecting' | 'disconnected' | 'error' }>({});
const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
+ const [refreshingNip65, setRefreshingNip65] = useState(false);
+ const { toast } = useToast();
useEffect(() => {
document.title = `Relays | LUMINA`;
@@ -40,6 +44,63 @@ export default function RelaysPage() {
}
}, [connectedRelays, refreshKey]);
+ // Function to refresh NIP-65 relays for the current user
+ const refreshNip65Relays = async () => {
+ try {
+ setRefreshingNip65(true);
+
+ // Get current user's public key from local storage
+ const pubkey = localStorage.getItem('pubkey');
+
+ if (!pubkey) {
+ toast({
+ title: "Error refreshing relays",
+ description: "You need to be logged in to refresh NIP-65 relays",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ // Default relays to query for NIP-65 data
+ const defaultRelays = [
+ "wss://relay.nostr.band",
+ "wss://relay.damus.io",
+ "wss://nos.lol",
+ "wss://relay.nostr.ch"
+ ];
+
+ // Fetch NIP-65 relays
+ const nip65Relays = await fetchNip65Relays(pubkey, defaultRelays);
+
+ if (nip65Relays.length > 0) {
+ // Merge with existing relays and store in localStorage
+ const mergedRelays = mergeAndStoreRelays(nip65Relays);
+
+ toast({
+ title: "NIP-65 relays updated",
+ description: `Found ${nip65Relays.length} relays in your NIP-65 list. Refresh the page to connect to them.`,
+ });
+
+ // Refresh page connection status
+ setRefreshKey(prev => prev + 1);
+ } else {
+ toast({
+ title: "No NIP-65 relays found",
+ description: "We couldn't find any NIP-65 relay preferences for your account",
+ });
+ }
+ } catch (error) {
+ console.error("Error refreshing NIP-65 relays:", error);
+ toast({
+ title: "Error refreshing relays",
+ description: "There was an error fetching your relay preferences",
+ variant: "destructive",
+ });
+ } finally {
+ setRefreshingNip65(false);
+ }
+ };
+
// Function to get the appropriate status icon
const getStatusIcon = (status: string) => {
switch (status) {
@@ -81,7 +142,18 @@ export default function RelaysPage() {
Nostr Relays
-
+
+
+
+
@@ -128,7 +200,7 @@ export default function RelaysPage() {
@@ -188,6 +260,14 @@ export default function RelaysPage() {
that makes the decentralized social network possible. You can connect to multiple relays to
increase the reach and resilience of your posts and profile.
+
+
NIP-65 Relay Lists
+
+ NIP-65 is a Nostr standard that allows users to share their preferred relays. When you log in,
+ LUMINA automatically fetches your relay preferences from the Nostr network and adds them to your
+ connection list. Use the "Refresh NIP-65 Relays" button above to manually update your relay list.
+
+
diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx
index 5d9849c..1047246 100644
--- a/components/LoginForm.tsx
+++ b/components/LoginForm.tsx
@@ -24,9 +24,10 @@ import {
import { useEffect, useRef, useState } from "react"
import { getPublicKey, generateSecretKey, nip19, SimplePool } from 'nostr-tools'
import { BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'
-import { InfoIcon } from "lucide-react";
+import { InfoIcon, Loader2 } from "lucide-react";
import Link from "next/link";
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
+import { fetchNip65Relays, mergeAndStoreRelays } from "@/utils/nip65Utils"
export function LoginForm() {
@@ -35,17 +36,72 @@ export function LoginForm() {
let npubInput = useRef(null);
let bunkerUrlInput = useRef(null);
const [isLoading, setIsLoading] = useState(false);
+ const [isBunkerLoading, setIsBunkerLoading] = useState(false);
+ const [isExtensionLoading, setIsExtensionLoading] = useState(false);
+ const [isAmberLoading, setIsAmberLoading] = useState(false);
+ const [isNsecLoading, setIsNsecLoading] = useState(false);
+ const [isNpubLoading, setIsNpubLoading] = useState(false);
const [bunkerError, setBunkerError] = useState(null);
+ // Default relays to query for NIP-65 data
+ const defaultRelays = [
+ "wss://relay.nostr.band",
+ "wss://relay.damus.io",
+ "wss://nos.lol",
+ "wss://relay.nostr.ch"
+ ];
+
+ // Helper function to load NIP-65 relays for a user
+ const loadNip65Relays = async (pubkey: string) => {
+ try {
+ // Fetch the user's relay preferences
+ const nip65Relays = await fetchNip65Relays(pubkey, defaultRelays);
+
+ if (nip65Relays.length > 0) {
+ // Merge with existing relays and store in localStorage
+ mergeAndStoreRelays(nip65Relays);
+ console.log(`Loaded ${nip65Relays.length} relays from NIP-65 for user ${pubkey}`);
+ } else {
+ console.log(`No NIP-65 relays found for user ${pubkey}`);
+ }
+ } catch (error) {
+ console.error("Error loading NIP-65 relays:", error);
+ }
+ };
+
+ // Function to complete login process
+ const completeLogin = async (pubkey: string, loginType: string, redirect = true) => {
+ try {
+ // Store the login info
+ localStorage.setItem("pubkey", pubkey);
+ localStorage.setItem("loginType", loginType);
+
+ // Load NIP-65 relays
+ await loadNip65Relays(pubkey);
+
+ // Redirect if needed
+ if (redirect) {
+ window.location.href = `/profile/${nip19.npubEncode(pubkey)}`;
+ }
+ } catch (error) {
+ console.error("Error completing login:", error);
+ // Reset all loading states in case of error
+ setIsLoading(false);
+ setIsBunkerLoading(false);
+ setIsExtensionLoading(false);
+ setIsAmberLoading(false);
+ setIsNsecLoading(false);
+ setIsNpubLoading(false);
+ }
+ };
+
useEffect(() => {
// handle Amber Login Response
const urlParams = new URLSearchParams(window.location.search);
const amberResponse = urlParams.get('amberResponse');
if (amberResponse !== null) {
- // localStorage.setItem("pubkey", nip19.npubEncode(amberResponse).toString());
- localStorage.setItem("pubkey", amberResponse);
- localStorage.setItem("loginType", "amber");
- window.location.href = `/profile/${amberResponse}`;
+ setIsAmberLoading(true);
+ completeLogin(amberResponse, "amber");
}
// Handle nostrconnect URL from bunker
@@ -58,6 +114,7 @@ export function LoginForm() {
const handleNostrConnect = async (url: string) => {
try {
setIsLoading(true);
+ setIsBunkerLoading(true);
setBunkerError(null);
// Generate local secret key for communicating with the bunker
@@ -83,25 +140,26 @@ export function LoginForm() {
const userPubkey = await bunker.getPublicKey();
// Store connection info in localStorage
- localStorage.setItem("pubkey", userPubkey);
- localStorage.setItem("loginType", "bunker");
localStorage.setItem("bunkerLocalKey", localSecretKeyHex);
localStorage.setItem("bunkerUrl", bunkerUrl);
- // Close the pool and redirect
+ // Close the pool
await bunker.close();
pool.close([]);
- window.location.href = `/profile/${nip19.npubEncode(userPubkey)}`;
+ // Complete login and redirect
+ await completeLogin(userPubkey, "bunker", true);
} catch (err) {
console.error("Bunker connection error:", err);
setBunkerError("Failed to connect to bunker. Please check the URL and try again.");
await bunker.close().catch(console.error);
pool.close([]);
+ setIsBunkerLoading(false);
}
} catch (err) {
console.error("Bunker parsing error:", err);
setBunkerError("Invalid bunker URL format.");
+ setIsBunkerLoading(false);
} finally {
setIsLoading(false);
}
@@ -117,49 +175,52 @@ export function LoginForm() {
};
const handleAmber = async () => {
- const hostname = window.location.host;
- console.log(hostname);
- if (!hostname) {
- throw new Error("Hostname is null or undefined");
+ try {
+ setIsAmberLoading(true);
+ setIsLoading(true);
+ const hostname = window.location.host;
+ console.log(hostname);
+ if (!hostname) {
+ throw new Error("Hostname is null or undefined");
+ }
+ const intent = `intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;S.callbackUrl=http://${hostname}/login?amberResponse=;end`;
+ window.location.href = intent;
+ // The loading state will be maintained until the callback returns or page unloads
+ } catch (error) {
+ console.error("Error launching Amber:", error);
+ setIsAmberLoading(false);
+ setIsLoading(false);
}
- const intent = `intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;S.callbackUrl=http://${hostname}/login?amberResponse=;end`;
- window.location.href = intent;
- // window.location.href = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=http://${hostname}/login?amberResponse=`;
}
const handleExtensionLogin = async () => {
- // eslint-disable-next-line
- if (window.nostr !== undefined) {
- publicKey.current = await window.nostr.getPublicKey()
- console.log("Logged in with pubkey: ", publicKey.current);
- if (publicKey.current !== null) {
- localStorage.setItem("pubkey", publicKey.current);
- localStorage.setItem("loginType", "extension");
- // window.location.reload();
- window.location.href = `/profile/${nip19.npubEncode(publicKey.current)}`;
+ try {
+ setIsExtensionLoading(true);
+ setIsLoading(true);
+ // eslint-disable-next-line
+ if (window.nostr !== undefined) {
+ publicKey.current = await window.nostr.getPublicKey()
+ console.log("Logged in with pubkey: ", publicKey.current);
+ if (publicKey.current !== null) {
+ await completeLogin(publicKey.current, "extension");
+ } else {
+ throw new Error("Failed to get public key from extension");
+ }
+ } else {
+ throw new Error("Nostr extension not detected");
}
+ } catch (error) {
+ console.error("Extension login error:", error);
+ setIsExtensionLoading(false);
+ setIsLoading(false);
}
};
- // const handleNsecSignUp = async () => {
- // let nsec = generateSecretKey();
- // console.log('nsec: ' + nsec);
-
- // let nsecHex = bytesToHex(nsec);
- // console.log('bytesToHex nsec: ' + nsecHex);
-
- // let pubkey = getPublicKey(nsec);
- // console.log('pubkey: ' + pubkey);
-
- // localStorage.setItem("nsec", nsecHex);
- // localStorage.setItem("pubkey", pubkey);
- // localStorage.setItem("loginType", "raw_nsec")
- // window.location.href = `/profile/${nip19.npubEncode(pubkey)}`;
- // };
-
const handleNsecLogin = async () => {
if (nsecInput.current !== null) {
try {
+ setIsNsecLoading(true);
+ setIsLoading(true);
let input = nsecInput.current.value;
if(input.includes("nsec")) {
input = bytesToHex(nip19.decode(input).data as Uint8Array);
@@ -170,12 +231,11 @@ export function LoginForm() {
let pubkey = getPublicKey(nsecBytes);
localStorage.setItem("nsec", nsecHex);
- localStorage.setItem("pubkey", pubkey);
- localStorage.setItem("loginType", "raw_nsec")
-
- window.location.href = `/profile/${nip19.npubEncode(pubkey)}`;
+ await completeLogin(pubkey, "raw_nsec");
} catch (e) {
console.error(e);
+ setIsNsecLoading(false);
+ setIsLoading(false);
}
}
};
@@ -183,6 +243,8 @@ export function LoginForm() {
const handleNpubLogin = async () => {
if (npubInput.current !== null) {
try {
+ setIsNpubLoading(true);
+ setIsLoading(true);
let input = npubInput.current.value;
let npub = null;
let pubkey = null;
@@ -194,17 +256,15 @@ export function LoginForm() {
npub = nip19.npubEncode(input);
}
- localStorage.setItem("pubkey", pubkey);
- localStorage.setItem("loginType", "readOnly_npub")
-
- window.location.href = `/profile/${npub}`;
+ await completeLogin(pubkey, "readOnly_npub");
} catch (e) {
console.error(e);
+ setIsNpubLoading(false);
+ setIsLoading(false);
}
}
};
-
return (
@@ -215,15 +275,37 @@ export function LoginForm() {
-
+
-
+
-
+
-
+
@@ -234,15 +316,25 @@ export function LoginForm() {
-
+
{bunkerError &&
{bunkerError}
}
-
@@ -271,9 +380,26 @@ export function LoginForm() {
-
- Sign in
-
+
+
+ {isNsecLoading ? (
+ <>
+
+ Signing in...
+ >
+ ) : "Sign in"}
+
+
diff --git a/utils/nip65Utils.ts b/utils/nip65Utils.ts
new file mode 100644
index 0000000..362b757
--- /dev/null
+++ b/utils/nip65Utils.ts
@@ -0,0 +1,99 @@
+import { SimplePool, Filter, Event } from 'nostr-tools';
+
+// Interface for NIP-65 relay with read/write permissions
+export interface Nip65Relay {
+ url: string;
+ read: boolean;
+ write: boolean;
+}
+
+/**
+ * Fetches NIP-65 relay list metadata for a specific user
+ * @param pubkey User's public key
+ * @param relays Relays to query for NIP-65 events
+ * @returns Object with parsed relay permissions
+ */
+export async function fetchNip65Relays(pubkey: string, relays: string[]): Promise {
+ // Create a pool for temporary use
+ const pool = new SimplePool();
+
+ try {
+ // Define filter for NIP-65 events (kind:10002)
+ const filter: Filter = {
+ kinds: [10002],
+ authors: [pubkey],
+ limit: 1, // We only need the most recent one
+ };
+
+ // Fetch the event (pool.get returns a single event or undefined)
+ const latestEvent = await pool.get(relays, filter);
+
+ if (!latestEvent) {
+ return [];
+ }
+
+ // Parse the relay tags
+ return parseNip65Event(latestEvent);
+ } catch (error) {
+ console.error('Error fetching NIP-65 relays:', error);
+ return [];
+ } finally {
+ // Close the pool to clean up connections
+ pool.close(relays);
+ }
+}
+
+/**
+ * Parses a NIP-65 event and extracts relay information
+ * @param event NIP-65 event (kind:10002)
+ * @returns Array of relays with read/write permissions
+ */
+export function parseNip65Event(event: Event): Nip65Relay[] {
+ if (event.kind !== 10002) {
+ return [];
+ }
+
+ const relays: Nip65Relay[] = [];
+
+ // Process each 'r' tag
+ for (const tag of event.tags) {
+ if (tag[0] === 'r' && tag[1]) {
+ const url = tag[1];
+ const permission = tag[2]?.toLowerCase();
+
+ // Default is both read and write if no permission specified
+ relays.push({
+ url,
+ read: permission ? permission.includes('read') : true,
+ write: permission ? permission.includes('write') : true
+ });
+ }
+ }
+
+ return relays;
+}
+
+/**
+ * Merges NIP-65 relays with existing custom relays and stores in localStorage
+ * @param nip65Relays NIP-65 relays to merge
+ */
+export function mergeAndStoreRelays(nip65Relays: Nip65Relay[]): string[] {
+ try {
+ // Get existing custom relays
+ const existingRelays = JSON.parse(localStorage.getItem("customRelays") || "[]");
+
+ // Extract URLs from NIP-65 relays (we'll add all relays for now, both read and write)
+ const nip65RelayUrls = nip65Relays.map(relay => relay.url);
+
+ // Merge existing and NIP-65 relays, removing duplicates
+ const mergedRelays = Array.from(new Set([...existingRelays, ...nip65RelayUrls]));
+
+ // Store updated list
+ localStorage.setItem("customRelays", JSON.stringify(mergedRelays));
+
+ return mergedRelays;
+ } catch (error) {
+ console.error('Error merging relays:', error);
+ return [];
+ }
+}
\ No newline at end of file