feat: user relay management, blog naddr sync, and OG image update

Add UserRelay API and dashboard relays tab with NIP-65 import, store post
naddr for Nostr articles, and ship Prisma migrations for board tables,
user relays, and post metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bbe
2026-06-23 05:54:34 +02:00
co-authored by Cursor
parent 78271ea110
commit 3bdd01dedf
19 changed files with 896 additions and 132 deletions
+56 -15
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { fetchEventFromRelays, fetchLongformFromRelays } from "@/lib/nostr";
import {
Pencil,
Trash2,
@@ -56,13 +57,24 @@ export default function BlogPage() {
setFetching(true);
setError("");
try {
const isNaddr = importInput.startsWith("naddr");
const data = await api.fetchNostrEvent(
isNaddr ? { naddr: importInput } : { eventId: importInput }
);
setImportPreview(data);
const isNaddr = importInput.trim().startsWith("naddr");
const event = isNaddr
? await fetchLongformFromRelays(importInput.trim())
: await fetchEventFromRelays(importInput.trim());
if (!event) {
setError("Event not found on relays");
setImportPreview(null);
return;
}
const titleTag = event.tags?.find((t: string[]) => t[0] === "title");
setImportPreview({
...event,
title: titleTag?.[1] || "Untitled",
});
} catch (err: any) {
setError(err.message);
setError(err.message || "Failed to fetch event");
setImportPreview(null);
} finally {
setFetching(false);
@@ -70,14 +82,29 @@ export default function BlogPage() {
};
const handleImport = async () => {
if (!importInput.trim()) return;
if (!importPreview) return;
setImporting(true);
setError("");
try {
const isNaddr = importInput.startsWith("naddr");
await api.importPost(
isNaddr ? { naddr: importInput } : { eventId: importInput }
);
const isNaddr = importInput.trim().startsWith("naddr");
const excerpt = (importPreview.content || "")
.slice(0, 200)
.replace(/[#*_\n]/g, "")
.trim();
const tags: string[] = (importPreview.tags || [])
.filter((t: string[]) => t[0] === "t" && t[1])
.map((t: string[]) => t[1].toLowerCase());
await api.importPost({
nostrEventId: importPreview.id,
naddr: isNaddr ? importInput.trim() : undefined,
title: importPreview.title || "Untitled",
excerpt: excerpt || undefined,
authorPubkey: importPreview.pubkey,
publishedAt: importPreview.created_at,
tags: tags.length > 0 ? tags : undefined,
});
setImportInput("");
setImportPreview(null);
setImportOpen(false);
@@ -95,7 +122,7 @@ export default function BlogPage() {
title: post.title || "",
slug: post.slug || "",
excerpt: post.excerpt || "",
categories: post.categories?.map((c: any) => c.id || c) || [],
categories: post.categories?.map((c: any) => c.categoryId || c.category?.id || c) || [],
featured: post.featured || false,
visible: post.visible !== false,
});
@@ -184,6 +211,20 @@ export default function BlogPage() {
<p className="text-on-surface/60 text-sm mt-1 line-clamp-3">
{importPreview.content?.slice(0, 300)}...
</p>
{(() => {
const previewTags = (importPreview.tags || [])
.filter((t: string[]) => t[0] === "t" && t[1])
.map((t: string[]) => t[1]);
return previewTags.length > 0 ? (
<div className="flex flex-wrap gap-1.5 mt-2">
{previewTags.map((tag: string) => (
<span key={tag} className="rounded-full px-2 py-0.5 text-xs bg-primary/10 text-primary font-medium">
{tag}
</span>
))}
</div>
) : null;
})()}
<button
onClick={handleImport}
disabled={importing}
@@ -303,12 +344,12 @@ export default function BlogPage() {
<p className="text-on-surface/50 text-sm truncate">/{post.slug}</p>
{post.categories?.length > 0 && (
<div className="flex gap-2 mt-2">
{post.categories.map((cat: any) => (
{post.categories.map((pc: any) => (
<span
key={cat.id || cat}
key={pc.categoryId || pc.category?.id}
className="rounded-full px-2 py-0.5 text-xs bg-surface-container-highest text-on-surface/60"
>
{cat.name || cat}
{pc.category?.name || pc.categoryId}
</span>
))}
</div>
+42 -9
View File
@@ -7,7 +7,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { hasNostrExtension, getPublicKey, signEvent, publishEvent, shortenPubkey, fetchNostrProfile, type NostrProfile } from "@/lib/nostr";
import { hasNostrExtension, getPublicKey, signEvent, publishEvent, shortenPubkey, fetchNostrProfile, fetchLongformFromRelays, fetchEventFromRelays, type NostrProfile } from "@/lib/nostr";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import type { Components } from "react-markdown";
@@ -23,6 +23,7 @@ interface Post {
publishedAt?: string;
createdAt?: string;
nostrEventId?: string;
naddr?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
}
@@ -147,6 +148,8 @@ export default function BlogPostClient({ slug }: { slug: string }) {
const [hasNostr, setHasNostr] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
const [liveContent, setLiveContent] = useState<string | null>(null);
const [loadingContent, setLoadingContent] = useState(false);
useEffect(() => {
setHasNostr(hasNostrExtension());
@@ -156,18 +159,36 @@ export default function BlogPostClient({ slug }: { slug: string }) {
if (!slug) return;
setLoading(true);
setError(null);
setLiveContent(null);
api
.getPost(slug)
.then((data) => {
setPost(data);
setLoading(false);
if (data?.authorPubkey) {
fetchNostrProfile(data.authorPubkey)
.then((profile) => setAuthorProfile(profile))
.catch(() => {});
}
if (!data?.content && (data?.naddr || data?.nostrEventId)) {
setLoadingContent(true);
const fetchPromise = data.naddr
? fetchLongformFromRelays(data.naddr)
: fetchEventFromRelays(data.nostrEventId!);
fetchPromise
.then((event) => {
if (event?.content) {
setLiveContent(event.content);
}
})
.catch(() => {})
.finally(() => setLoadingContent(false));
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, [slug]);
useEffect(() => {
@@ -306,12 +327,24 @@ export default function BlogPostClient({ slug }: { slug: string }) {
</header>
<article className="mb-16">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{post.content}
</ReactMarkdown>
{loadingContent ? (
<div className="animate-pulse space-y-4">
{[85, 92, 78, 95, 88, 72, 90, 83].map((w, i) => (
<div
key={i}
className="h-4 bg-surface-container-high rounded"
style={{ width: `${w}%` }}
/>
))}
</div>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{post.content || liveContent || ""}
</ReactMarkdown>
)}
</article>
<section className="bg-surface-container-low rounded-xl p-8 mb-16">
+4 -4
View File
@@ -18,7 +18,7 @@ interface Post {
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
categories?: { id: string; name: string; slug: string }[];
categories?: { category: { id: string; name: string; slug: string } }[];
featured?: boolean;
}
@@ -212,12 +212,12 @@ export default function BlogPage() {
>
{post.categories && post.categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.categories.map((cat) => (
{post.categories.map((pc) => (
<span
key={cat.id}
key={pc.category.id}
className="text-primary text-[10px] uppercase tracking-widest font-bold"
>
{cat.name}
{pc.category.name}
</span>
))}
</div>
+215 -3
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import Image from "next/image";
import { Send, FileText, Clock, CheckCircle, XCircle, Plus, User, Loader2, AtSign } from "lucide-react";
import { Send, FileText, Clock, CheckCircle, XCircle, Plus, User, Loader2, AtSign, Radio, Trash2, Download, Eye, Pencil } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import { shortenPubkey } from "@/lib/nostr";
@@ -37,7 +37,15 @@ const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; classNa
},
};
type Tab = "submissions" | "profile";
interface UserRelay {
id: string;
url: string;
read: boolean;
write: boolean;
createdAt: string;
}
type Tab = "submissions" | "profile" | "relays";
type UsernameStatus =
| { state: "idle" }
@@ -69,6 +77,15 @@ export default function DashboardPage() {
const [hostname, setHostname] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Relay state
const [userRelays, setUserRelays] = useState<UserRelay[]>([]);
const [loadingRelays, setLoadingRelays] = useState(true);
const [newRelayUrl, setNewRelayUrl] = useState("");
const [addingRelay, setAddingRelay] = useState(false);
const [importingNip65, setImportingNip65] = useState(false);
const [relayError, setRelayError] = useState("");
const [relaySuccess, setRelaySuccess] = useState("");
const displayName = user?.name || user?.displayName || shortenPubkey(user?.pubkey || "");
useEffect(() => {
@@ -92,9 +109,21 @@ export default function DashboardPage() {
}
}, []);
const loadRelays = useCallback(async () => {
try {
const data = await api.getUserRelays();
setUserRelays(data);
} catch {
// Silently handle
} finally {
setLoadingRelays(false);
}
}, []);
useEffect(() => {
loadSubmissions();
}, [loadSubmissions]);
loadRelays();
}, [loadSubmissions, loadRelays]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -130,6 +159,63 @@ export default function DashboardPage() {
}
};
const handleAddRelay = async (e: React.FormEvent) => {
e.preventDefault();
setRelayError("");
setRelaySuccess("");
const url = newRelayUrl.trim();
if (!url) {
setRelayError("Relay URL is required");
return;
}
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
setRelayError("URL must start with wss:// or ws://");
return;
}
setAddingRelay(true);
try {
await api.addUserRelay({ url });
setNewRelayUrl("");
setRelaySuccess("Relay added");
await loadRelays();
} catch (err: any) {
setRelayError(err.message || "Failed to add relay");
} finally {
setAddingRelay(false);
}
};
const handleRemoveRelay = async (id: string) => {
setRelayError("");
try {
await api.removeUserRelay(id);
setUserRelays((prev) => prev.filter((r) => r.id !== id));
} catch (err: any) {
setRelayError(err.message || "Failed to remove relay");
}
};
const handleImportNip65 = async () => {
setRelayError("");
setRelaySuccess("");
setImportingNip65(true);
try {
const result = await api.importNip65Relays();
if (result.imported > 0) {
setRelaySuccess(`Imported ${result.imported} relay(s) from your NIP-65 list`);
await loadRelays();
} else {
setRelayError(result.message || "No NIP-65 relay list found for your pubkey");
}
} catch (err: any) {
setRelayError(err.message || "Failed to import NIP-65 relays");
} finally {
setImportingNip65(false);
}
};
const handleUsernameChange = (value: string) => {
setUsername(value);
setSaveError("");
@@ -247,6 +333,17 @@ export default function DashboardPage() {
<User size={16} />
Profile
</button>
<button
onClick={() => setActiveTab("relays")}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-colors ${
activeTab === "relays"
? "border-primary text-primary"
: "border-transparent text-on-surface-variant hover:text-on-surface"
}`}
>
<Radio size={16} />
Relays
</button>
</div>
{/* Submissions tab */}
@@ -516,6 +613,121 @@ export default function DashboardPage() {
</form>
</section>
)}
{/* Relays tab */}
{activeTab === "relays" && (
<section>
<h2 className="text-xl font-bold text-on-surface mb-2">Your Relays</h2>
<p className="text-on-surface-variant text-sm mb-8">
Manage the Nostr relays used for your interactions on this site.
These relays are used when publishing events and fetching your content.
You can also import your relay list from your Nostr profile (NIP-65).
</p>
{relaySuccess && (
<div className="bg-green-400/10 text-green-400 rounded-lg px-4 py-3 text-sm mb-6">
{relaySuccess}
</div>
)}
{relayError && (
<div className="bg-error/10 text-error rounded-lg px-4 py-3 text-sm mb-6">
{relayError}
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 mb-8">
<form onSubmit={handleAddRelay} className="flex-1 flex gap-3">
<input
type="text"
value={newRelayUrl}
onChange={(e) => setNewRelayUrl(e.target.value)}
placeholder="wss://relay.example.com"
className="flex-1 bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<Button
variant="primary"
size="md"
type="submit"
disabled={addingRelay}
>
<span className="flex items-center gap-2">
<Plus size={16} />
{addingRelay ? "Adding…" : "Add"}
</span>
</Button>
</form>
<Button
variant="secondary"
size="md"
type="button"
onClick={handleImportNip65}
disabled={importingNip65}
>
<span className="flex items-center gap-2">
{importingNip65 ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
{importingNip65 ? "Importing…" : "Import from NIP-65"}
</span>
</Button>
</div>
{loadingRelays ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse bg-surface-container-low rounded-xl p-5">
<div className="h-5 w-2/3 bg-surface-container-high rounded" />
</div>
))}
</div>
) : userRelays.length === 0 ? (
<div className="bg-surface-container-low rounded-xl p-8 text-center">
<Radio size={32} className="text-on-surface-variant/30 mx-auto mb-3" />
<p className="text-on-surface-variant/60 text-sm">
No relays configured. Add a relay manually or import from your NIP-65 profile.
</p>
</div>
) : (
<div className="space-y-3">
{userRelays.map((relay) => (
<div
key={relay.id}
className="bg-surface-container-low rounded-xl px-5 py-4 flex items-center justify-between gap-4"
>
<div className="flex-1 min-w-0">
<p className="font-mono text-sm text-on-surface truncate">
{relay.url}
</p>
<div className="flex gap-2 mt-1.5">
{relay.read && (
<span className="flex items-center gap-1 text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded-full">
<Eye size={12} />
Read
</span>
)}
{relay.write && (
<span className="flex items-center gap-1 text-xs font-semibold text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full">
<Pencil size={12} />
Write
</span>
)}
</div>
</div>
<button
onClick={() => handleRemoveRelay(relay.id)}
className="text-on-surface-variant/50 hover:text-error transition-colors p-2 rounded-lg hover:bg-error/10"
title="Remove relay"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</section>
)}
</div>
);
}
+3 -3
View File
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
template: "%s | Belgian Bitcoin Embassy",
},
description:
"Belgium's sovereign Bitcoin community. Monthly meetups in Antwerp, Bitcoin education, and curated Nostr content. No hype, just signal.",
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
keywords: [
"Bitcoin",
"Belgium",
@@ -44,7 +44,7 @@ export const metadata: Metadata = {
siteName: "Belgian Bitcoin Embassy",
title: "Belgian Bitcoin Embassy | Bitcoin Meetups & Education in Belgium",
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
images: [
{
url: "/og-default.png",
@@ -58,7 +58,7 @@ export const metadata: Metadata = {
card: "summary_large_image",
title: "Belgian Bitcoin Embassy",
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
images: ["/og-default.png"],
},
robots: {
+2 -2
View File
@@ -24,7 +24,7 @@ export function OrganizationJsonLd() {
url: siteUrl,
logo: `${siteUrl}/og-default.png`,
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
sameAs: ["https://t.me/belgianbitcoinembassy"],
address: {
"@type": "PostalAddress",
@@ -45,7 +45,7 @@ export function WebSiteJsonLd() {
name: "Belgian Bitcoin Embassy",
url: siteUrl,
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
publisher: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
+20 -1
View File
@@ -43,7 +43,15 @@ export const api = {
request<{ count: number; reactions: any[] }>(`/posts/${slug}/reactions`),
getPostReplies: (slug: string) =>
request<{ count: number; replies: any[] }>(`/posts/${slug}/replies`),
importPost: (data: { eventId?: string; naddr?: string }) =>
importPost: (data: {
nostrEventId: string;
naddr?: string;
title: string;
excerpt?: string;
authorPubkey: string;
publishedAt?: number;
tags?: string[];
}) =>
request<any>("/posts/import", { method: "POST", body: JSON.stringify(data) }),
updatePost: (id: string, data: any) =>
request<any>(`/posts/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
@@ -116,6 +124,7 @@ export const api = {
request<void>(`/organizers/${id}`, { method: "DELETE" }),
// Relays
getPublicRelays: () => request<{ relays: string[] }>("/relays/public"),
getRelays: () => request<any[]>("/relays"),
addRelay: (data: { url: string; priority?: number }) =>
request<any>("/relays", { method: "POST", body: JSON.stringify(data) }),
@@ -126,6 +135,16 @@ export const api = {
testRelay: (id: string) =>
request<{ success: boolean }>(`/relays/${id}/test`, { method: "POST" }),
// User Relays
getUserRelays: () =>
request<any[]>("/user-relays"),
addUserRelay: (data: { url: string; read?: boolean; write?: boolean }) =>
request<any>("/user-relays", { method: "POST", body: JSON.stringify(data) }),
removeUserRelay: (id: string) =>
request<void>(`/user-relays/${id}`, { method: "DELETE" }),
importNip65Relays: () =>
request<{ imported: number; total?: number; message?: string }>("/user-relays/import-nip65", { method: "POST" }),
// Settings
getSettings: () => request<Record<string, string>>("/settings"),
getPublicSettings: () => request<Record<string, string>>("/settings/public"),
+191 -8
View File
@@ -56,24 +56,143 @@ export interface NostrProfile {
displayName?: string;
}
const DEFAULT_RELAYS = [
const FALLBACK_RELAYS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band",
];
let _siteRelaysCache: { relays: string[]; fetchedAt: number } | null = null;
const SITE_RELAY_TTL = 5 * 60 * 1000; // 5 minutes
export async function getSiteRelays(): Promise<string[]> {
if (_siteRelaysCache && Date.now() - _siteRelaysCache.fetchedAt < SITE_RELAY_TTL) {
return _siteRelaysCache.relays;
}
try {
const res = await fetch("/api/relays/public");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (Array.isArray(data.relays) && data.relays.length > 0) {
_siteRelaysCache = { relays: data.relays, fetchedAt: Date.now() };
return data.relays;
}
} catch {}
return FALLBACK_RELAYS;
}
export interface Nip65RelayList {
write: string[];
read: string[];
all: string[];
}
const _nip65Cache = new Map<string, { data: Nip65RelayList; fetchedAt: number }>();
const NIP65_TTL = 10 * 60 * 1000; // 10 minutes
export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayList> {
const cached = _nip65Cache.get(pubkey);
if (cached && Date.now() - cached.fetchedAt < NIP65_TTL) return cached.data;
const { SimplePool } = await import("nostr-tools/pool");
const siteRelays = await getSiteRelays();
const pool = new SimplePool();
try {
const event = await pool.get(siteRelays, {
kinds: [10002],
authors: [pubkey],
});
const result: Nip65RelayList = { write: [], read: [], all: [] };
if (!event) {
_nip65Cache.set(pubkey, { data: result, fetchedAt: Date.now() });
return result;
}
for (const tag of event.tags) {
if (tag[0] !== "r" || !tag[1]) continue;
const url = tag[1];
const marker = tag[2];
result.all.push(url);
if (marker === "write") {
result.write.push(url);
} else if (marker === "read") {
result.read.push(url);
} else {
result.write.push(url);
result.read.push(url);
}
}
_nip65Cache.set(pubkey, { data: result, fetchedAt: Date.now() });
return result;
} catch {
return { write: [], read: [], all: [] };
} finally {
pool.close(siteRelays);
}
}
let _userRelaysCache: { relays: { url: string; read: boolean; write: boolean }[]; fetchedAt: number } | null = null;
const USER_RELAY_TTL = 2 * 60 * 1000; // 2 minutes
export async function getUserStoredRelays(): Promise<{ url: string; read: boolean; write: boolean }[]> {
if (_userRelaysCache && Date.now() - _userRelaysCache.fetchedAt < USER_RELAY_TTL) {
return _userRelaysCache.relays;
}
try {
const token = typeof window !== "undefined" ? localStorage.getItem("bbe_token") : null;
if (!token) return [];
const res = await fetch("/api/user-relays", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return [];
const data = await res.json();
if (Array.isArray(data)) {
_userRelaysCache = { relays: data, fetchedAt: Date.now() };
return data;
}
} catch {}
return [];
}
export function clearUserRelaysCache(): void {
_userRelaysCache = null;
}
export async function publishEvent(signedEvent: any): Promise<void> {
const { SimplePool } = await import("nostr-tools/pool");
let relayUrls: string[] = DEFAULT_RELAYS;
const siteRelays = await getSiteRelays();
const allRelays = new Set<string>(siteRelays);
// Merge user's stored write relays
try {
const stored = await getUserStoredRelays();
stored
.filter((r) => r.write)
.forEach((r) => allRelays.add(r.url));
} catch {}
// Merge NIP-65 write relays for the event author
if (signedEvent.pubkey) {
try {
const nip65 = await fetchNip65RelayList(signedEvent.pubkey);
nip65.write.forEach((url) => allRelays.add(url));
} catch {}
}
// Merge extension relays
try {
if (window.nostr?.getRelays) {
const ext = await window.nostr.getRelays();
const write = Object.entries(ext)
Object.entries(ext)
.filter(([, p]) => (p as any).write)
.map(([url]) => url);
if (write.length > 0) relayUrls = write;
.forEach(([url]) => allRelays.add(url));
}
} catch {}
const relayUrls = [...allRelays];
const pool = new SimplePool();
try {
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
@@ -84,13 +203,21 @@ export async function publishEvent(signedEvent: any): Promise<void> {
export async function fetchNostrProfile(
pubkey: string,
relayUrls: string[] = DEFAULT_RELAYS
relayUrls?: string[]
): Promise<NostrProfile> {
const { SimplePool } = await import("nostr-tools/pool");
const allRelays = new Set<string>(relayUrls || await getSiteRelays());
try {
const nip65 = await fetchNip65RelayList(pubkey);
nip65.write.forEach((url) => allRelays.add(url));
} catch {}
const urls = [...allRelays];
const pool = new SimplePool();
try {
const event = await pool.get(relayUrls, {
const event = await pool.get(urls, {
kinds: [0],
authors: [pubkey],
});
@@ -107,6 +234,61 @@ export async function fetchNostrProfile(
};
} catch {
return {};
} finally {
pool.close(urls);
}
}
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
const { SimplePool } = await import("nostr-tools/pool");
const siteRelays = await getSiteRelays();
const pool = new SimplePool();
try {
const event = await pool.get(siteRelays, { ids: [eventId] });
return event || null;
} catch {
return null;
} finally {
pool.close(siteRelays);
}
}
export async function fetchLongformFromRelays(naddrStr: string): Promise<any | null> {
const { SimplePool } = await import("nostr-tools/pool");
const { nip19 } = await import("nostr-tools");
let decoded: { kind: number; pubkey: string; identifier: string; relays?: string[] };
try {
const result = nip19.decode(naddrStr);
if (result.type !== "naddr") return null;
decoded = result.data;
} catch {
return null;
}
const siteRelays = await getSiteRelays();
const naddrRelays = decoded.relays || [];
const allRelays = new Set<string>([...naddrRelays, ...siteRelays]);
try {
const nip65 = await fetchNip65RelayList(decoded.pubkey);
nip65.write.forEach((url) => allRelays.add(url));
} catch {}
const relayUrls = [...allRelays];
const filter = {
kinds: [decoded.kind],
authors: [decoded.pubkey],
"#d": [decoded.identifier],
};
const pool = new SimplePool();
try {
const event = await pool.get(relayUrls, filter);
return event || null;
} catch {
return null;
} finally {
pool.close(relayUrls);
}
@@ -128,8 +310,9 @@ export async function createBunkerSigner(
// NIP-46: Generate a nostrconnect:// URI for QR display
export async function generateNostrConnectSetup(
relayUrls: string[] = DEFAULT_RELAYS.slice(0, 2)
relayUrls?: string[]
): Promise<{ uri: string; clientSecretKey: Uint8Array }> {
if (!relayUrls) relayUrls = (await getSiteRelays()).slice(0, 2);
const { createNostrConnectURI } = await import("nostr-tools/nip46");
const clientSecretKey = generateSecretKey();
const clientPubkey = getPubKeyFromSecret(clientSecretKey);
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB