Feature: Add NIP-65 (Load User Relays) (#74)

* feat: add NIP-65 utility functions for relay management

- Implemented fetchNip65Relays to retrieve relay permissions for a user.
- Added parseNip65Event to extract relay information from NIP-65 events.
- Created mergeAndStoreRelays to combine NIP-65 relays with existing custom relays and store them in localStorage.

* feat: enhance LoginForm with loading states and improved UI feedback for login actions

---------

Co-authored-by: highperfocused <highperfocused@pm.me>
This commit is contained in:
mroxso
2025-04-18 23:07:48 +02:00
committed by GitHub
parent 8842400f2a
commit ac7de1e1f4
4 changed files with 418 additions and 71 deletions

42
.github/prompts/nostr-nip65.prompt.md vendored Normal file
View File

@@ -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).

View File

@@ -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() {
<div className="py-4 px-2 md:py-6 md:px-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Nostr Relays</h2>
<AddRelaySheet onRelayAdded={handleRelayAdded} />
<div className="flex space-x-2">
<Button
variant="outline"
className="gap-2"
onClick={refreshNip65Relays}
disabled={refreshingNip65}
>
<RefreshCw className={`h-4 w-4 ${refreshingNip65 ? 'animate-spin' : ''}`} />
{refreshingNip65 ? 'Refreshing NIP-65...' : 'Refresh NIP-65 Relays'}
</Button>
<AddRelaySheet onRelayAdded={handleRelayAdded} />
</div>
</div>
<Tabs defaultValue="list">
@@ -128,7 +200,7 @@ export default function RelaysPage() {
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" onClick={() => window.location.reload()}>
Refresh
Refresh Connection Status
</Button>
</CardFooter>
</Card>
@@ -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.
</p>
<div className="mt-3">
<h3 className="text-sm font-medium mb-1">NIP-65 Relay Lists</h3>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
</CardContent>
</Card>
</div>

View File

@@ -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<HTMLInputElement>(null);
let bunkerUrlInput = useRef<HTMLInputElement>(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<string | null>(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 (
<Card className="w-full max-w-xl">
<CardHeader>
@@ -215,15 +275,37 @@ export function LoginForm() {
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid grid-cols-8 gap-2">
<Button className="w-full col-span-7" onClick={handleExtensionLogin}>Sign in with Extension (NIP-07)</Button>
<Button
className="w-full col-span-7"
onClick={handleExtensionLogin}
disabled={isLoading || isExtensionLoading}
>
{isExtensionLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : "Sign in with Extension (NIP-07)"}
</Button>
<Link target="_blank" href="https://www.getflamingo.org/">
<Button variant={"outline"}><InfoIcon /></Button>
<Button variant={"outline"} disabled={isLoading}><InfoIcon /></Button>
</Link>
</div>
<div className="grid grid-cols-8 gap-2">
<Button className="w-full col-span-7" onClick={handleAmber}>Sign in with Amber</Button>
<Button
className="w-full col-span-7"
onClick={handleAmber}
disabled={isLoading || isAmberLoading}
>
{isAmberLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : "Sign in with Amber"}
</Button>
<Link target="_blank" href="https://github.com/greenart7c3/Amber">
<Button variant={"outline"}><InfoIcon /></Button>
<Button variant={"outline"} disabled={isLoading}><InfoIcon /></Button>
</Link>
</div>
<hr />
@@ -234,15 +316,25 @@ export function LoginForm() {
<AccordionContent>
<div className="grid gap-2">
<Label htmlFor="bunkerUrl">Bunker URL</Label>
<Input placeholder="bunker://... or nostrconnect://..."
id="bunkerUrl"
ref={bunkerUrlInput}
type="text" />
<Input
placeholder="bunker://... or nostrconnect://..."
id="bunkerUrl"
ref={bunkerUrlInput}
type="text"
disabled={isLoading || isBunkerLoading}
/>
{bunkerError && <p className="text-red-500 text-sm">{bunkerError}</p>}
<Button className="w-full"
onClick={handleBunkerLogin}
disabled={isLoading}>
{isLoading ? "Connecting..." : "Sign in with Bunker"}
<Button
className="w-full"
onClick={handleBunkerLogin}
disabled={isLoading || isBunkerLoading}
>
{isBunkerLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : "Sign in with Bunker"}
</Button>
<p className="text-sm text-muted-foreground">
Use a NIP-46 compatible bunker URL that starts with bunker:// or nostrconnect://
@@ -258,9 +350,26 @@ export function LoginForm() {
<AccordionContent>
<div className="grid gap-2">
<Label htmlFor="npub">npub</Label>
<Input placeholder="npub1..." id="npub" ref={npubInput} type="text" />
<Button className="w-full" onClick={handleNpubLogin}>Sign in</Button>
</div>
<Input
placeholder="npub1..."
id="npub"
ref={npubInput}
type="text"
disabled={isLoading || isNpubLoading}
/>
<Button
className="w-full"
onClick={handleNpubLogin}
disabled={isLoading || isNpubLoading}
>
{isNpubLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : "Sign in"}
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
@@ -271,9 +380,26 @@ export function LoginForm() {
<AccordionContent>
<div className="grid gap-2">
<Label htmlFor="nsec">nsec</Label>
<Input placeholder="nsecabcdefghijklmnopqrstuvwxyz" id="nsec" ref={nsecInput} type="password" />
<Button className="w-full" onClick={handleNsecLogin}>Sign in</Button>
</div>
<Input
placeholder="nsecabcdefghijklmnopqrstuvwxyz"
id="nsec"
ref={nsecInput}
type="password"
disabled={isLoading || isNsecLoading}
/>
<Button
className="w-full"
onClick={handleNsecLogin}
disabled={isLoading || isNsecLoading}
>
{isNsecLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : "Sign in"}
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>

99
utils/nip65Utils.ts Normal file
View File

@@ -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<Nip65Relay[]> {
// 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 [];
}
}