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}

} -

Use a NIP-46 compatible bunker URL that starts with bunker:// or nostrconnect:// @@ -258,9 +350,26 @@ export function LoginForm() {

- - -
+ + +
@@ -271,9 +380,26 @@ export function LoginForm() {
- - -
+ + +
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