mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-04-08 22:46:49 +02:00
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:
42
.github/prompts/nostr-nip65.prompt.md
vendored
Normal file
42
.github/prompts/nostr-nip65.prompt.md
vendored
Normal 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).
|
||||
@@ -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>
|
||||
|
||||
@@ -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
99
utils/nip65Utils.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user