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:
@@ -0,0 +1,29 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "BoardInvoicePending" (
|
||||||
|
"paymentHash" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"guestName" TEXT,
|
||||||
|
"pubkey" TEXT,
|
||||||
|
"profilePic" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "BoardMessage" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"paymentHash" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"authorName" TEXT NOT NULL,
|
||||||
|
"pubkey" TEXT,
|
||||||
|
"profilePic" TEXT,
|
||||||
|
"satsPaid" INTEGER NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'active',
|
||||||
|
"likeCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "BoardMessage_paymentHash_key" ON "BoardMessage"("paymentHash");
|
||||||
|
|
||||||
|
-- DropIndex (cleanup from organizer migration)
|
||||||
|
DROP INDEX IF EXISTS "Meetup_organizerId_idx";
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserRelay" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"pubkey" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"read" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"write" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UserRelay_pubkey_url_key" ON "UserRelay"("pubkey", "url");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable: add naddr column to Post
|
||||||
|
ALTER TABLE "Post" ADD COLUMN "naddr" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable: set default for content on Post (new rows only; existing rows keep their content)
|
||||||
|
-- SQLite doesn't support ALTER COLUMN DEFAULT, but Prisma handles this at the client level.
|
||||||
@@ -61,9 +61,10 @@ model Media {
|
|||||||
model Post {
|
model Post {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
nostrEventId String @unique
|
nostrEventId String @unique
|
||||||
|
naddr String?
|
||||||
title String
|
title String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
content String
|
content String @default("")
|
||||||
excerpt String?
|
excerpt String?
|
||||||
authorPubkey String
|
authorPubkey String
|
||||||
authorName String?
|
authorName String?
|
||||||
@@ -117,6 +118,17 @@ model Relay {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserRelay {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
pubkey String
|
||||||
|
url String
|
||||||
|
read Boolean @default(true)
|
||||||
|
write Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([pubkey, url])
|
||||||
|
}
|
||||||
|
|
||||||
model Setting {
|
model Setting {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|||||||
+48
-30
@@ -69,54 +69,72 @@ router.post(
|
|||||||
requireRole(['ADMIN', 'MODERATOR']),
|
requireRole(['ADMIN', 'MODERATOR']),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { eventId, naddr } = req.body;
|
const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags } = req.body;
|
||||||
if (!eventId && !naddr) {
|
|
||||||
res.status(400).json({ error: 'eventId or naddr is required' });
|
if (!nostrEventId || !title || !authorPubkey) {
|
||||||
|
res.status(400).json({ error: 'nostrEventId, title, and authorPubkey are required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let event: any = null;
|
|
||||||
if (naddr) {
|
|
||||||
event = await nostrService.fetchLongformEvent(naddr);
|
|
||||||
} else if (eventId) {
|
|
||||||
event = await nostrService.fetchEvent(eventId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
res.status(404).json({ error: 'Event not found on relays' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleTag = event.tags?.find((t: string[]) => t[0] === 'title');
|
|
||||||
const title = titleTag?.[1] || 'Untitled';
|
|
||||||
|
|
||||||
const slugBase = title
|
const slugBase = title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
.replace(/^-|-$/g, '');
|
.replace(/^-|-$/g, '');
|
||||||
const slug = `${slugBase}-${event.id.slice(0, 8)}`;
|
const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`;
|
||||||
|
|
||||||
const excerpt = event.content.slice(0, 200).replace(/[#*_\n]/g, '').trim();
|
|
||||||
|
|
||||||
const post = await prisma.post.upsert({
|
const post = await prisma.post.upsert({
|
||||||
where: { nostrEventId: event.id },
|
where: { nostrEventId },
|
||||||
update: {
|
update: {
|
||||||
title,
|
title,
|
||||||
content: event.content,
|
excerpt: excerpt || undefined,
|
||||||
excerpt,
|
naddr: naddr || undefined,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
nostrEventId: event.id,
|
nostrEventId,
|
||||||
|
naddr: naddr || null,
|
||||||
title,
|
title,
|
||||||
slug,
|
slug,
|
||||||
content: event.content,
|
excerpt: excerpt || null,
|
||||||
excerpt,
|
authorPubkey,
|
||||||
authorPubkey: event.pubkey,
|
publishedAt: publishedAt
|
||||||
publishedAt: new Date(event.created_at * 1000),
|
? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt)
|
||||||
|
: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(post);
|
if (Array.isArray(tags) && tags.length > 0) {
|
||||||
|
const categoryIds: string[] = [];
|
||||||
|
for (const tag of tags as string[]) {
|
||||||
|
const catSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
if (!catSlug) continue;
|
||||||
|
const category = await prisma.category.upsert({
|
||||||
|
where: { slug: catSlug },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: tag.charAt(0).toUpperCase() + tag.slice(1),
|
||||||
|
slug: catSlug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
categoryIds.push(category.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.postCategory.deleteMany({ where: { postId: post.id } });
|
||||||
|
if (categoryIds.length > 0) {
|
||||||
|
await prisma.postCategory.createMany({
|
||||||
|
data: categoryIds.map((categoryId) => ({
|
||||||
|
postId: post.id,
|
||||||
|
categoryId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.post.findUnique({
|
||||||
|
where: { id: post.id },
|
||||||
|
include: { categories: { include: { category: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Import post error:', err);
|
console.error('Import post error:', err);
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ import { requireAuth, requireRole } from '../middleware/auth';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/public',
|
||||||
|
async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const relays = await prisma.relay.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { priority: 'asc' },
|
||||||
|
});
|
||||||
|
res.json({ relays: relays.map((r) => r.url) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Public relays error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
import { requireAuth } from '../middleware/auth';
|
||||||
|
import { nostrService } from '../services/nostr';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
requireAuth,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const relays = await prisma.userRelay.findMany({
|
||||||
|
where: { pubkey: req.user!.pubkey },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
res.json(relays);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('List user relays error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
requireAuth,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { url, read, write } = req.body;
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
res.status(400).json({ error: 'url is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = url.trim().replace(/\/+$/, '');
|
||||||
|
if (!normalized.startsWith('wss://') && !normalized.startsWith('ws://')) {
|
||||||
|
res.status(400).json({ error: 'Relay URL must start with wss:// or ws://' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relay = await prisma.userRelay.upsert({
|
||||||
|
where: {
|
||||||
|
pubkey_url: { pubkey: req.user!.pubkey, url: normalized },
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
read: read !== undefined ? read : true,
|
||||||
|
write: write !== undefined ? write : true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
pubkey: req.user!.pubkey,
|
||||||
|
url: normalized,
|
||||||
|
read: read !== undefined ? read : true,
|
||||||
|
write: write !== undefined ? write : true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(relay);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Add user relay error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
requireAuth,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const relay = await prisma.userRelay.findUnique({
|
||||||
|
where: { id: req.params.id as string },
|
||||||
|
});
|
||||||
|
if (!relay || relay.pubkey !== req.user!.pubkey) {
|
||||||
|
res.status(404).json({ error: 'Relay not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userRelay.delete({ where: { id: req.params.id as string } });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete user relay error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/import-nip65',
|
||||||
|
requireAuth,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const pubkey = req.user!.pubkey;
|
||||||
|
const nip65 = await nostrService.fetchNip65Relays(pubkey);
|
||||||
|
|
||||||
|
const allUrls = new Set<string>();
|
||||||
|
const relayEntries: { url: string; read: boolean; write: boolean }[] = [];
|
||||||
|
|
||||||
|
for (const url of nip65.write) {
|
||||||
|
if (allUrls.has(url)) continue;
|
||||||
|
allUrls.add(url);
|
||||||
|
relayEntries.push({
|
||||||
|
url,
|
||||||
|
read: nip65.read.includes(url),
|
||||||
|
write: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const url of nip65.read) {
|
||||||
|
if (allUrls.has(url)) continue;
|
||||||
|
allUrls.add(url);
|
||||||
|
relayEntries.push({ url, read: true, write: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relayEntries.length === 0) {
|
||||||
|
res.json({ imported: 0, message: 'No NIP-65 relay list found for your pubkey' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
for (const entry of relayEntries) {
|
||||||
|
await prisma.userRelay.upsert({
|
||||||
|
where: { pubkey_url: { pubkey, url: entry.url } },
|
||||||
|
update: { read: entry.read, write: entry.write },
|
||||||
|
create: { pubkey, url: entry.url, read: entry.read, write: entry.write },
|
||||||
|
});
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ imported, total: relayEntries.length });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Import NIP-65 error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -24,6 +24,7 @@ import calendarRouter from './api/calendar';
|
|||||||
import nip05Router from './api/nip05';
|
import nip05Router from './api/nip05';
|
||||||
import messagesRouter from './api/messages';
|
import messagesRouter from './api/messages';
|
||||||
import adminMessagesRouter from './api/adminMessages';
|
import adminMessagesRouter from './api/adminMessages';
|
||||||
|
import userRelaysRouter from './api/userRelays';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
|
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
|
||||||
@@ -54,6 +55,7 @@ app.use('/api/calendar', calendarRouter);
|
|||||||
app.use('/api/nip05', nip05Router);
|
app.use('/api/nip05', nip05Router);
|
||||||
app.use('/api/messages', messagesRouter);
|
app.use('/api/messages', messagesRouter);
|
||||||
app.use('/api/admin/messages', adminMessagesRouter);
|
app.use('/api/admin/messages', adminMessagesRouter);
|
||||||
|
app.use('/api/user-relays', userRelaysRouter);
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ status: 'ok' });
|
res.json({ status: 'ok' });
|
||||||
|
|||||||
+101
-56
@@ -11,7 +11,62 @@ async function getRelayUrls(): Promise<string[]> {
|
|||||||
return relays.map((r) => r.url);
|
return relays.map((r) => r.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dedupeRelays(...lists: string[][]): string[] {
|
||||||
|
return [...new Set(lists.flat().filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheEvent(event: any) {
|
||||||
|
await prisma.nostrEventCache.upsert({
|
||||||
|
where: { eventId: event.id },
|
||||||
|
update: {
|
||||||
|
content: event.content,
|
||||||
|
tags: JSON.stringify(event.tags),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
eventId: event.id,
|
||||||
|
kind: event.kind,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
content: event.content,
|
||||||
|
tags: JSON.stringify(event.tags),
|
||||||
|
createdAt: event.created_at,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const nostrService = {
|
export const nostrService = {
|
||||||
|
async fetchNip65Relays(pubkey: string): Promise<{ write: string[]; read: string[] }> {
|
||||||
|
const siteRelays = await getRelayUrls();
|
||||||
|
if (siteRelays.length === 0) return { write: [], read: [] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await pool.get(siteRelays, {
|
||||||
|
kinds: [10002],
|
||||||
|
authors: [pubkey],
|
||||||
|
});
|
||||||
|
if (!event) return { write: [], read: [] };
|
||||||
|
|
||||||
|
const write: string[] = [];
|
||||||
|
const read: string[] = [];
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] !== 'r' || !tag[1]) continue;
|
||||||
|
const url = tag[1];
|
||||||
|
const marker = tag[2];
|
||||||
|
if (marker === 'write') {
|
||||||
|
write.push(url);
|
||||||
|
} else if (marker === 'read') {
|
||||||
|
read.push(url);
|
||||||
|
} else {
|
||||||
|
write.push(url);
|
||||||
|
read.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { write, read };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch NIP-65 relays for', pubkey, err);
|
||||||
|
return { write: [], read: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async fetchEvent(eventId: string, skipCache = false) {
|
async fetchEvent(eventId: string, skipCache = false) {
|
||||||
if (!skipCache) {
|
if (!skipCache) {
|
||||||
const cached = await prisma.nostrEventCache.findUnique({
|
const cached = await prisma.nostrEventCache.findUnique({
|
||||||
@@ -29,34 +84,20 @@ export const nostrService = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const relays = await getRelayUrls();
|
const siteRelays = await getRelayUrls();
|
||||||
if (relays.length === 0) return null;
|
if (siteRelays.length === 0) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const event = await pool.get(relays, { ids: [eventId] });
|
const event = await pool.get(siteRelays, { ids: [eventId] });
|
||||||
if (!event) return null;
|
if (event) {
|
||||||
|
await cacheEvent(event);
|
||||||
await prisma.nostrEventCache.upsert({
|
return event;
|
||||||
where: { eventId: event.id },
|
}
|
||||||
update: {
|
|
||||||
content: event.content,
|
|
||||||
tags: JSON.stringify(event.tags),
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
eventId: event.id,
|
|
||||||
kind: event.kind,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
content: event.content,
|
|
||||||
tags: JSON.stringify(event.tags),
|
|
||||||
createdAt: event.created_at,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return event;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch event:', err);
|
console.error('Failed to fetch event from site relays:', err);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchLongformEvent(naddrStr: string) {
|
async fetchLongformEvent(naddrStr: string) {
|
||||||
@@ -69,40 +110,44 @@ export const nostrService = {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const relays = decoded.relays?.length
|
const siteRelays = await getRelayUrls();
|
||||||
? decoded.relays
|
const naddrRelays = decoded.relays || [];
|
||||||
: await getRelayUrls();
|
const filter = {
|
||||||
if (relays.length === 0) return null;
|
kinds: [decoded.kind],
|
||||||
|
authors: [decoded.pubkey],
|
||||||
|
'#d': [decoded.identifier],
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
// Try naddr-embedded relays first, then site relays
|
||||||
const event = await pool.get(relays, {
|
const firstTry = dedupeRelays(naddrRelays, siteRelays);
|
||||||
kinds: [decoded.kind],
|
if (firstTry.length > 0) {
|
||||||
authors: [decoded.pubkey],
|
try {
|
||||||
'#d': [decoded.identifier],
|
const event = await pool.get(firstTry, filter);
|
||||||
});
|
if (event) {
|
||||||
if (!event) return null;
|
await cacheEvent(event);
|
||||||
|
return event;
|
||||||
await prisma.nostrEventCache.upsert({
|
}
|
||||||
where: { eventId: event.id },
|
} catch (err) {
|
||||||
update: {
|
console.error('Failed to fetch longform event (first pass):', err);
|
||||||
content: event.content,
|
}
|
||||||
tags: JSON.stringify(event.tags),
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
eventId: event.id,
|
|
||||||
kind: event.kind,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
content: event.content,
|
|
||||||
tags: JSON.stringify(event.tags),
|
|
||||||
createdAt: event.created_at,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return event;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch longform event:', err);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: try author's NIP-65 write relays
|
||||||
|
const nip65 = await nostrService.fetchNip65Relays(decoded.pubkey);
|
||||||
|
if (nip65.write.length > 0) {
|
||||||
|
const nip65Relays = dedupeRelays(nip65.write);
|
||||||
|
try {
|
||||||
|
const event = await pool.get(nip65Relays, filter);
|
||||||
|
if (event) {
|
||||||
|
await cacheEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch longform event (NIP-65 fallback):', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchReactions(eventId: string) {
|
async fetchReactions(eventId: string) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { fetchEventFromRelays, fetchLongformFromRelays } from "@/lib/nostr";
|
||||||
import {
|
import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -56,13 +57,24 @@ export default function BlogPage() {
|
|||||||
setFetching(true);
|
setFetching(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const isNaddr = importInput.startsWith("naddr");
|
const isNaddr = importInput.trim().startsWith("naddr");
|
||||||
const data = await api.fetchNostrEvent(
|
const event = isNaddr
|
||||||
isNaddr ? { naddr: importInput } : { eventId: importInput }
|
? await fetchLongformFromRelays(importInput.trim())
|
||||||
);
|
: await fetchEventFromRelays(importInput.trim());
|
||||||
setImportPreview(data);
|
|
||||||
|
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) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message || "Failed to fetch event");
|
||||||
setImportPreview(null);
|
setImportPreview(null);
|
||||||
} finally {
|
} finally {
|
||||||
setFetching(false);
|
setFetching(false);
|
||||||
@@ -70,14 +82,29 @@ export default function BlogPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
if (!importInput.trim()) return;
|
if (!importPreview) return;
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const isNaddr = importInput.startsWith("naddr");
|
const isNaddr = importInput.trim().startsWith("naddr");
|
||||||
await api.importPost(
|
const excerpt = (importPreview.content || "")
|
||||||
isNaddr ? { naddr: importInput } : { eventId: importInput }
|
.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("");
|
setImportInput("");
|
||||||
setImportPreview(null);
|
setImportPreview(null);
|
||||||
setImportOpen(false);
|
setImportOpen(false);
|
||||||
@@ -95,7 +122,7 @@ export default function BlogPage() {
|
|||||||
title: post.title || "",
|
title: post.title || "",
|
||||||
slug: post.slug || "",
|
slug: post.slug || "",
|
||||||
excerpt: post.excerpt || "",
|
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,
|
featured: post.featured || false,
|
||||||
visible: post.visible !== 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">
|
<p className="text-on-surface/60 text-sm mt-1 line-clamp-3">
|
||||||
{importPreview.content?.slice(0, 300)}...
|
{importPreview.content?.slice(0, 300)}...
|
||||||
</p>
|
</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
|
<button
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
@@ -303,12 +344,12 @@ export default function BlogPage() {
|
|||||||
<p className="text-on-surface/50 text-sm truncate">/{post.slug}</p>
|
<p className="text-on-surface/50 text-sm truncate">/{post.slug}</p>
|
||||||
{post.categories?.length > 0 && (
|
{post.categories?.length > 0 && (
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
{post.categories.map((cat: any) => (
|
{post.categories.map((pc: any) => (
|
||||||
<span
|
<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"
|
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>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ReactMarkdown from "react-markdown";
|
|||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { formatDate } from "@/lib/utils";
|
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 { Navbar } from "@/components/public/Navbar";
|
||||||
import { Footer } from "@/components/public/Footer";
|
import { Footer } from "@/components/public/Footer";
|
||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
@@ -23,6 +23,7 @@ interface Post {
|
|||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
nostrEventId?: string;
|
nostrEventId?: string;
|
||||||
|
naddr?: string;
|
||||||
categories?: { category: { id: string; name: string; slug: 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 [hasNostr, setHasNostr] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
|
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
|
||||||
|
const [liveContent, setLiveContent] = useState<string | null>(null);
|
||||||
|
const [loadingContent, setLoadingContent] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasNostr(hasNostrExtension());
|
setHasNostr(hasNostrExtension());
|
||||||
@@ -156,18 +159,36 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setLiveContent(null);
|
||||||
api
|
api
|
||||||
.getPost(slug)
|
.getPost(slug)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setPost(data);
|
setPost(data);
|
||||||
|
setLoading(false);
|
||||||
if (data?.authorPubkey) {
|
if (data?.authorPubkey) {
|
||||||
fetchNostrProfile(data.authorPubkey)
|
fetchNostrProfile(data.authorPubkey)
|
||||||
.then((profile) => setAuthorProfile(profile))
|
.then((profile) => setAuthorProfile(profile))
|
||||||
.catch(() => {});
|
.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))
|
.catch((err) => {
|
||||||
.finally(() => setLoading(false));
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -306,12 +327,24 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<article className="mb-16">
|
<article className="mb-16">
|
||||||
<ReactMarkdown
|
{loadingContent ? (
|
||||||
remarkPlugins={[remarkGfm]}
|
<div className="animate-pulse space-y-4">
|
||||||
components={markdownComponents}
|
{[85, 92, 78, 95, 88, 72, 90, 83].map((w, i) => (
|
||||||
>
|
<div
|
||||||
{post.content}
|
key={i}
|
||||||
</ReactMarkdown>
|
className="h-4 bg-surface-container-high rounded"
|
||||||
|
style={{ width: `${w}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={markdownComponents}
|
||||||
|
>
|
||||||
|
{post.content || liveContent || ""}
|
||||||
|
</ReactMarkdown>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<section className="bg-surface-container-low rounded-xl p-8 mb-16">
|
<section className="bg-surface-container-low rounded-xl p-8 mb-16">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface Post {
|
|||||||
authorPubkey?: string;
|
authorPubkey?: string;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
categories?: { id: string; name: string; slug: string }[];
|
categories?: { category: { id: string; name: string; slug: string } }[];
|
||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,12 +212,12 @@ export default function BlogPage() {
|
|||||||
>
|
>
|
||||||
{post.categories && post.categories.length > 0 && (
|
{post.categories && post.categories.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
{post.categories.map((cat) => (
|
{post.categories.map((pc) => (
|
||||||
<span
|
<span
|
||||||
key={cat.id}
|
key={pc.category.id}
|
||||||
className="text-primary text-[10px] uppercase tracking-widest font-bold"
|
className="text-primary text-[10px] uppercase tracking-widest font-bold"
|
||||||
>
|
>
|
||||||
{cat.name}
|
{pc.category.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import Image from "next/image";
|
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 { useAuth } from "@/hooks/useAuth";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { shortenPubkey } from "@/lib/nostr";
|
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 =
|
type UsernameStatus =
|
||||||
| { state: "idle" }
|
| { state: "idle" }
|
||||||
@@ -69,6 +77,15 @@ export default function DashboardPage() {
|
|||||||
const [hostname, setHostname] = useState("");
|
const [hostname, setHostname] = useState("");
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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 || "");
|
const displayName = user?.name || user?.displayName || shortenPubkey(user?.pubkey || "");
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
loadSubmissions();
|
loadSubmissions();
|
||||||
}, [loadSubmissions]);
|
loadRelays();
|
||||||
|
}, [loadSubmissions, loadRelays]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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) => {
|
const handleUsernameChange = (value: string) => {
|
||||||
setUsername(value);
|
setUsername(value);
|
||||||
setSaveError("");
|
setSaveError("");
|
||||||
@@ -247,6 +333,17 @@ export default function DashboardPage() {
|
|||||||
<User size={16} />
|
<User size={16} />
|
||||||
Profile
|
Profile
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Submissions tab */}
|
{/* Submissions tab */}
|
||||||
@@ -516,6 +613,121 @@ export default function DashboardPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
|
|||||||
template: "%s | Belgian Bitcoin Embassy",
|
template: "%s | Belgian Bitcoin Embassy",
|
||||||
},
|
},
|
||||||
description:
|
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: [
|
keywords: [
|
||||||
"Bitcoin",
|
"Bitcoin",
|
||||||
"Belgium",
|
"Belgium",
|
||||||
@@ -44,7 +44,7 @@ export const metadata: Metadata = {
|
|||||||
siteName: "Belgian Bitcoin Embassy",
|
siteName: "Belgian Bitcoin Embassy",
|
||||||
title: "Belgian Bitcoin Embassy | Bitcoin Meetups & Education in Belgium",
|
title: "Belgian Bitcoin Embassy | Bitcoin Meetups & Education in Belgium",
|
||||||
description:
|
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: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/og-default.png",
|
url: "/og-default.png",
|
||||||
@@ -58,7 +58,7 @@ export const metadata: Metadata = {
|
|||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "Belgian Bitcoin Embassy",
|
title: "Belgian Bitcoin Embassy",
|
||||||
description:
|
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"],
|
images: ["/og-default.png"],
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function OrganizationJsonLd() {
|
|||||||
url: siteUrl,
|
url: siteUrl,
|
||||||
logo: `${siteUrl}/og-default.png`,
|
logo: `${siteUrl}/og-default.png`,
|
||||||
description:
|
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"],
|
sameAs: ["https://t.me/belgianbitcoinembassy"],
|
||||||
address: {
|
address: {
|
||||||
"@type": "PostalAddress",
|
"@type": "PostalAddress",
|
||||||
@@ -45,7 +45,7 @@ export function WebSiteJsonLd() {
|
|||||||
name: "Belgian Bitcoin Embassy",
|
name: "Belgian Bitcoin Embassy",
|
||||||
url: siteUrl,
|
url: siteUrl,
|
||||||
description:
|
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: {
|
publisher: {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
name: "Belgian Bitcoin Embassy",
|
name: "Belgian Bitcoin Embassy",
|
||||||
|
|||||||
+20
-1
@@ -43,7 +43,15 @@ export const api = {
|
|||||||
request<{ count: number; reactions: any[] }>(`/posts/${slug}/reactions`),
|
request<{ count: number; reactions: any[] }>(`/posts/${slug}/reactions`),
|
||||||
getPostReplies: (slug: string) =>
|
getPostReplies: (slug: string) =>
|
||||||
request<{ count: number; replies: any[] }>(`/posts/${slug}/replies`),
|
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) }),
|
request<any>("/posts/import", { method: "POST", body: JSON.stringify(data) }),
|
||||||
updatePost: (id: string, data: any) =>
|
updatePost: (id: string, data: any) =>
|
||||||
request<any>(`/posts/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
request<any>(`/posts/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||||
@@ -116,6 +124,7 @@ export const api = {
|
|||||||
request<void>(`/organizers/${id}`, { method: "DELETE" }),
|
request<void>(`/organizers/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
// Relays
|
// Relays
|
||||||
|
getPublicRelays: () => request<{ relays: string[] }>("/relays/public"),
|
||||||
getRelays: () => request<any[]>("/relays"),
|
getRelays: () => request<any[]>("/relays"),
|
||||||
addRelay: (data: { url: string; priority?: number }) =>
|
addRelay: (data: { url: string; priority?: number }) =>
|
||||||
request<any>("/relays", { method: "POST", body: JSON.stringify(data) }),
|
request<any>("/relays", { method: "POST", body: JSON.stringify(data) }),
|
||||||
@@ -126,6 +135,16 @@ export const api = {
|
|||||||
testRelay: (id: string) =>
|
testRelay: (id: string) =>
|
||||||
request<{ success: boolean }>(`/relays/${id}/test`, { method: "POST" }),
|
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
|
// Settings
|
||||||
getSettings: () => request<Record<string, string>>("/settings"),
|
getSettings: () => request<Record<string, string>>("/settings"),
|
||||||
getPublicSettings: () => request<Record<string, string>>("/settings/public"),
|
getPublicSettings: () => request<Record<string, string>>("/settings/public"),
|
||||||
|
|||||||
+191
-8
@@ -56,24 +56,143 @@ export interface NostrProfile {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_RELAYS = [
|
const FALLBACK_RELAYS = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
"wss://nos.lol",
|
"wss://nos.lol",
|
||||||
"wss://relay.nostr.band",
|
"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> {
|
export async function publishEvent(signedEvent: any): Promise<void> {
|
||||||
const { SimplePool } = await import("nostr-tools/pool");
|
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 {
|
try {
|
||||||
if (window.nostr?.getRelays) {
|
if (window.nostr?.getRelays) {
|
||||||
const ext = await window.nostr.getRelays();
|
const ext = await window.nostr.getRelays();
|
||||||
const write = Object.entries(ext)
|
Object.entries(ext)
|
||||||
.filter(([, p]) => (p as any).write)
|
.filter(([, p]) => (p as any).write)
|
||||||
.map(([url]) => url);
|
.forEach(([url]) => allRelays.add(url));
|
||||||
if (write.length > 0) relayUrls = write;
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
const relayUrls = [...allRelays];
|
||||||
const pool = new SimplePool();
|
const pool = new SimplePool();
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
|
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
|
||||||
@@ -84,13 +203,21 @@ export async function publishEvent(signedEvent: any): Promise<void> {
|
|||||||
|
|
||||||
export async function fetchNostrProfile(
|
export async function fetchNostrProfile(
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
relayUrls: string[] = DEFAULT_RELAYS
|
relayUrls?: string[]
|
||||||
): Promise<NostrProfile> {
|
): Promise<NostrProfile> {
|
||||||
const { SimplePool } = await import("nostr-tools/pool");
|
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();
|
const pool = new SimplePool();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const event = await pool.get(relayUrls, {
|
const event = await pool.get(urls, {
|
||||||
kinds: [0],
|
kinds: [0],
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
});
|
});
|
||||||
@@ -107,6 +234,61 @@ export async function fetchNostrProfile(
|
|||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
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 {
|
} finally {
|
||||||
pool.close(relayUrls);
|
pool.close(relayUrls);
|
||||||
}
|
}
|
||||||
@@ -128,8 +310,9 @@ export async function createBunkerSigner(
|
|||||||
|
|
||||||
// NIP-46: Generate a nostrconnect:// URI for QR display
|
// NIP-46: Generate a nostrconnect:// URI for QR display
|
||||||
export async function generateNostrConnectSetup(
|
export async function generateNostrConnectSetup(
|
||||||
relayUrls: string[] = DEFAULT_RELAYS.slice(0, 2)
|
relayUrls?: string[]
|
||||||
): Promise<{ uri: string; clientSecretKey: Uint8Array }> {
|
): Promise<{ uri: string; clientSecretKey: Uint8Array }> {
|
||||||
|
if (!relayUrls) relayUrls = (await getSiteRelays()).slice(0, 2);
|
||||||
const { createNostrConnectURI } = await import("nostr-tools/nip46");
|
const { createNostrConnectURI } = await import("nostr-tools/nip46");
|
||||||
const clientSecretKey = generateSecretKey();
|
const clientSecretKey = generateSecretKey();
|
||||||
const clientPubkey = getPubKeyFromSecret(clientSecretKey);
|
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 |
Reference in New Issue
Block a user