feat: use negentropy as much as possible

This commit is contained in:
2024-10-08 10:36:31 +07:00
parent 8c6aea8050
commit d2b5ae0507
12 changed files with 202 additions and 209 deletions

View File

@@ -1,3 +1,4 @@
wss://relay.damus.io, wss://relay.damus.io,
wss://relay.nostr.net, wss://relay.nostr.net,
wss://nos.lol, wss://nostr.fmt.wiz.biz,
wss://offchain.pub,

View File

@@ -10,8 +10,12 @@ use std::{
time::Duration, time::Duration,
}; };
use tauri::{Emitter, Manager, State}; use tauri::{Emitter, Manager, State};
use tokio::time::sleep;
use crate::{common::init_nip65, Nostr, NOTIFICATION_SUB_ID}; use crate::{
common::{get_latest_event, init_nip65},
Nostr, NOTIFICATION_SUB_ID,
};
#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[derive(Debug, Clone, Serialize, Deserialize, Type)]
struct Account { struct Account {
@@ -217,6 +221,17 @@ pub fn is_account_sync(id: String, handle: tauri::AppHandle) -> bool {
fs::metadata(config_dir.join(id)).is_ok() fs::metadata(config_dir.join(id)).is_ok()
} }
#[tauri::command]
#[specta::specta]
pub fn create_sync_file(id: String, handle: tauri::AppHandle) -> bool {
let config_dir = handle
.path()
.app_config_dir()
.expect("Error: app config directory not found.");
File::create(config_dir.join(id)).is_ok()
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn login( pub async fn login(
@@ -273,93 +288,27 @@ pub async fn login(
// NIP-03: Get user's contact list // NIP-03: Get user's contact list
let contact_list = { let contact_list = {
let contacts = client.get_contact_list(None).await.unwrap(); if let Ok(contacts) = client.get_contact_list(Some(Duration::from_secs(5))).await {
// Update app's state state.contact_list.lock().await.clone_from(&contacts);
state.contact_list.lock().await.clone_from(&contacts); contacts
// Return } else {
contacts Vec::new()
}
}; };
// Run seperate thread for syncing data let public_key_clone = public_key.clone();
let pk = public_key.clone();
// Run seperate thread for sync
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let config_dir = handle.path().app_config_dir().unwrap();
let state = handle.state::<Nostr>(); let state = handle.state::<Nostr>();
let client = &state.client; let client = &state.client;
let author = PublicKey::from_str(&public_key).unwrap();
// Convert current user to PublicKey
let author = PublicKey::from_str(&pk).unwrap();
// Fetching user's metadata
if let Ok(report) = client
.reconcile(
Filter::new()
.author(author)
.kinds(vec![
Kind::Metadata,
Kind::ContactList,
Kind::MuteList,
Kind::Bookmarks,
Kind::Interests,
Kind::InterestSet,
Kind::FollowSet,
Kind::PinList,
Kind::EventDeletion,
])
.limit(1000),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len())
}
// Fetching user's events
if let Ok(report) = client
.reconcile(
Filter::new()
.author(author)
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(200),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len())
}
// Fetching user's notification
if let Ok(report) = client
.reconcile(
Filter::new()
.pubkey(author)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
])
.limit(200),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len())
}
// Subscribe for new notification // Subscribe for new notification
if let Ok(e) = client if let Ok(e) = client
.subscribe_with_id( .subscribe_with_id(
SubscriptionId::new(NOTIFICATION_SUB_ID), SubscriptionId::new(NOTIFICATION_SUB_ID),
vec![Filter::new() vec![Filter::new().pubkey(author).since(Timestamp::now())],
.pubkey(author)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
])
.since(Timestamp::now())],
None, None,
) )
.await .await
@@ -371,13 +320,82 @@ pub async fn login(
if !contact_list.is_empty() { if !contact_list.is_empty() {
let authors: Vec<PublicKey> = contact_list.iter().map(|f| f.public_key).collect(); let authors: Vec<PublicKey> = contact_list.iter().map(|f| f.public_key).collect();
// Fetching contact's metadata // Syncing all metadata events from contact list
if let Ok(report) = client if let Ok(report) = client
.reconcile( .reconcile(
Filter::new() Filter::new()
.authors(authors.clone()) .authors(authors.clone())
.kinds(vec![Kind::Metadata, Kind::ContactList, Kind::MuteList]) .kinds(vec![Kind::Metadata, Kind::ContactList])
.limit(3000), .limit(authors.len() * 10),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len());
}
// Syncing all events from contact list
if let Ok(report) = client
.reconcile(
Filter::new()
.authors(authors.clone())
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(authors.len() * 50),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len());
}
// Create the trusted public key list from contact list
// TODO: create a cached file
let mut trusted_list: HashSet<PublicKey> = HashSet::new();
for author in authors.into_iter() {
trusted_list.insert(author);
let filter = Filter::new()
.author(author)
.kind(Kind::ContactList)
.limit(1);
if let Ok(events) = client.database().query(vec![filter]).await {
if let Some(event) = get_latest_event(&events) {
for tag in event.tags.iter() {
if let Some(TagStandard::PublicKey {
public_key,
uppercase: false,
..
}) = tag.to_owned().to_standardized()
{
trusted_list.insert(public_key);
};
}
}
}
}
// Update app's state
state.trusted_list.lock().await.clone_from(&trusted_list);
// Syncing all user's events
if let Ok(report) = client
.reconcile(Filter::new().author(author), NegentropyOptions::default())
.await
{
println!("Received: {}", report.received.len())
}
// Syncing all tagged events for current user
if let Ok(report) = client
.reconcile(
Filter::new().pubkey(author).kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
]),
NegentropyOptions::default(), NegentropyOptions::default(),
) )
.await .await
@@ -385,57 +403,30 @@ pub async fn login(
println!("Received: {}", report.received.len()) println!("Received: {}", report.received.len())
} }
// Fetching contact's events // Syncing all events for trusted list
let trusted: Vec<PublicKey> = trusted_list.into_iter().collect();
if let Ok(report) = client if let Ok(report) = client
.reconcile( .reconcile(
Filter::new() Filter::new()
.authors(authors.clone()) .authors(trusted)
.kinds(vec![Kind::TextNote, Kind::Repost]) .kinds(vec![Kind::Metadata, Kind::TextNote, Kind::Repost])
.limit(1000), .limit(20000),
NegentropyOptions::default(), NegentropyOptions::default(),
) )
.await .await
{ {
println!("Received: {}", report.received.len()); println!("Received: {}", report.received.len())
// Save the process status
let _ = File::create(config_dir.join(author.to_bech32().unwrap()));
// Update frontend
handle.emit("synchronized", ()).unwrap();
};
for author in authors.into_iter() {
let filter = Filter::new()
.author(author)
.kind(Kind::ContactList)
.limit(1);
let mut circles = state.circles.lock().await;
let mut list: Vec<PublicKey> = Vec::new();
if let Ok(events) = client.database().query(vec![filter]).await {
if let Some(event) = events.into_iter().next() {
for tag in event.tags.into_iter() {
if let Some(TagStandard::PublicKey {
public_key,
uppercase: false,
..
}) = tag.to_standardized()
{
list.push(public_key)
}
}
if !list.is_empty() {
circles.insert(author, list);
};
}
}
} }
} else {
handle.emit("synchronized", ()).unwrap(); // Wait a little longer
// TODO: remove?
sleep(Duration::from_secs(5)).await;
} }
handle
.emit("neg_synchronized", ())
.expect("Something wrong!");
}); });
Ok(public_key) Ok(public_key_clone)
} }

View File

@@ -48,7 +48,7 @@ pub async fn get_profile(id: Option<String>, state: State<'_, Nostr>) -> Result<
match client.database().query(vec![filter.clone()]).await { match client.database().query(vec![filter.clone()]).await {
Ok(events) => { Ok(events) => {
if let Some(event) = events.first() { if let Some(event) = get_latest_event(&events) {
if let Ok(metadata) = Metadata::from_json(&event.content) { if let Ok(metadata) = Metadata::from_json(&event.content) {
Ok(metadata.as_json()) Ok(metadata.as_json())
} else { } else {
@@ -63,7 +63,7 @@ pub async fn get_profile(id: Option<String>, state: State<'_, Nostr>) -> Result<
.await .await
{ {
Ok(events) => { Ok(events) => {
if let Some(event) = events.first() { if let Some(event) = get_latest_event(&events) {
if let Ok(metadata) = Metadata::from_json(&event.content) { if let Ok(metadata) = Metadata::from_json(&event.content) {
Ok(metadata.as_json()) Ok(metadata.as_json())
} else { } else {
@@ -652,9 +652,8 @@ pub async fn verify_nip05(id: String, nip05: &str) -> Result<bool, String> {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn is_trusted_user(id: String, state: State<'_, Nostr>) -> Result<bool, String> { pub async fn is_trusted_user(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let circles = &state.circles.lock().await; let trusted_list = &state.trusted_list.lock().await;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?; let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
let trusted = circles.values().any(|v| v.contains(&public_key));
Ok(trusted) Ok(trusted_list.contains(&public_key))
} }

View File

@@ -7,13 +7,12 @@
use border::WebviewWindowExt as BorderWebviewWindowExt; use border::WebviewWindowExt as BorderWebviewWindowExt;
use commands::{account::*, event::*, metadata::*, relay::*, window::*}; use commands::{account::*, event::*, metadata::*, relay::*, window::*};
use common::parse_event; use common::parse_event;
use nostr_relay_builder::prelude::*; use nostr_sdk::prelude::{Profile as DatabaseProfile, *};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use specta_typescript::Typescript; use specta_typescript::Typescript;
use std::{ use std::{
collections::HashMap, collections::HashSet,
fs, fs,
io::{self, BufRead}, io::{self, BufRead},
str::FromStr, str::FromStr,
@@ -32,7 +31,7 @@ pub struct Nostr {
client: Client, client: Client,
settings: Mutex<Settings>, settings: Mutex<Settings>,
contact_list: Mutex<Vec<Contact>>, contact_list: Mutex<Vec<Contact>>,
circles: Mutex<HashMap<PublicKey, Vec<PublicKey>>>, trusted_list: Mutex<HashSet<PublicKey>>,
} }
#[derive(Clone, Serialize, Deserialize, Type)] #[derive(Clone, Serialize, Deserialize, Type)]
@@ -84,7 +83,6 @@ struct NewSettings(Settings);
pub const DEFAULT_DIFFICULTY: u8 = 21; pub const DEFAULT_DIFFICULTY: u8 = 21;
pub const FETCH_LIMIT: usize = 100; pub const FETCH_LIMIT: usize = 100;
pub const NEWSFEED_NEG_LIMIT: usize = 512;
pub const NOTIFICATION_NEG_LIMIT: usize = 64; pub const NOTIFICATION_NEG_LIMIT: usize = 64;
pub const NOTIFICATION_SUB_ID: &str = "lume_notification"; pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
@@ -108,6 +106,7 @@ fn main() {
delete_account, delete_account,
reset_password, reset_password,
is_account_sync, is_account_sync,
create_sync_file,
login, login,
get_profile, get_profile,
set_profile, set_profile,
@@ -274,7 +273,7 @@ fn main() {
} }
// Connect // Connect
client.connect().await; client.connect_with_timeout(Duration::from_secs(20)).await;
client client
}); });
@@ -284,7 +283,7 @@ fn main() {
client, client,
settings: Mutex::new(Settings::default()), settings: Mutex::new(Settings::default()),
contact_list: Mutex::new(Vec::new()), contact_list: Mutex::new(Vec::new()),
circles: Mutex::new(HashMap::new()), trusted_list: Mutex::new(HashSet::new()),
}); });
Subscription::listen_any(app, move |event| { Subscription::listen_any(app, move |event| {
@@ -432,14 +431,15 @@ fn main() {
// Send native notification // Send native notification
if allow_notification { if allow_notification {
let author = client let author = client
.fetch_metadata( .database()
event.pubkey, .profile(event.pubkey)
Some(Duration::from_secs(3)),
)
.await .await
.unwrap_or_else(|_| Metadata::new()); .unwrap_or_else(|_| {
DatabaseProfile::new(event.pubkey, Metadata::new())
});
let metadata = author.metadata();
send_event_notification(&event, author, &handle_clone); send_event_notification(&event, metadata, &handle_clone);
} }
} }
@@ -472,30 +472,36 @@ fn main() {
if let tauri::WindowEvent::Focused(focused) = event { if let tauri::WindowEvent::Focused(focused) = event {
if !focused { if !focused {
let handle = window.app_handle().to_owned(); let handle = window.app_handle().to_owned();
let config_dir = handle.path().app_config_dir().unwrap();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>(); let state = handle.state::<Nostr>();
let client = &state.client; let client = &state.client;
if client.signer().await.is_ok() { if let Ok(signer) = client.signer().await {
if let Ok(contact_list) = let public_key = signer.public_key().await.unwrap();
client.get_contact_list(Some(Duration::from_secs(5))).await let bech32 = public_key.to_bech32().unwrap();
{
let authors: Vec<PublicKey> =
contact_list.iter().map(|f| f.public_key).collect();
if client if fs::metadata(config_dir.join(bech32)).is_ok() {
.reconcile( if let Ok(contact_list) =
Filter::new() client.get_contact_list(Some(Duration::from_secs(5))).await
.authors(authors)
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(NEWSFEED_NEG_LIMIT),
NegentropyOptions::default(),
)
.await
.is_ok()
{ {
handle.emit("synchronized", ()).unwrap(); let authors: Vec<PublicKey> =
contact_list.iter().map(|f| f.public_key).collect();
if client
.reconcile(
Filter::new()
.authors(authors)
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(1000),
NegentropyOptions::default(),
)
.await
.is_ok()
{
handle.emit("synchronized", ()).unwrap();
}
} }
} }
} }

View File

@@ -99,6 +99,9 @@ async resetPassword(key: string, password: string) : Promise<Result<null, string
async isAccountSync(id: string) : Promise<boolean> { async isAccountSync(id: string) : Promise<boolean> {
return await TAURI_INVOKE("is_account_sync", { id }); return await TAURI_INVOKE("is_account_sync", { id });
}, },
async createSyncFile(id: string) : Promise<boolean> {
return await TAURI_INVOKE("create_sync_file", { id });
},
async login(account: string, password: string) : Promise<Result<string, string>> { async login(account: string, password: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("login", { account, password }) }; return { status: "ok", data: await TAURI_INVOKE("login", { account, password }) };

View File

@@ -12,7 +12,7 @@ export const RepostNote = memo(function RepostNote({
event: LumeEvent; event: LumeEvent;
className?: string; className?: string;
}) { }) {
const { isLoading, isError, data } = useEvent(event.repostId); const { isLoading, isError, data } = useEvent(event.repostId, event.content);
return ( return (
<Note.Root className={cn("", className)}> <Note.Root className={cn("", className)}>

View File

@@ -3,6 +3,7 @@ import { appSettings } from "@/commons";
import { Spinner } from "@/components"; import { Spinner } from "@/components";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import type { OsType } from "@tauri-apps/plugin-os"; import type { OsType } from "@tauri-apps/plugin-os";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -17,6 +18,8 @@ export const Route = createRootRouteWithContext<RouterContext>()({
}); });
function Screen() { function Screen() {
const { queryClient } = Route.useRouteContext();
useEffect(() => { useEffect(() => {
const unlisten = events.newSettings.listen((data) => { const unlisten = events.newSettings.listen((data) => {
appSettings.setState((state) => { appSettings.setState((state) => {
@@ -29,6 +32,16 @@ function Screen() {
}; };
}, []); }, []);
useEffect(() => {
const unlisten = listen("synchronized", async () => {
await queryClient.invalidateQueries();
});
return () => {
unlisten.then((f) => f());
};
}, []);
return <Outlet />; return <Outlet />;
} }

View File

@@ -7,8 +7,7 @@ import { ArrowDown } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event"; import { useCallback, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/columns/_layout/groups/$id")({ export const Route = createLazyFileRoute("/columns/_layout/groups/$id")({
@@ -18,7 +17,6 @@ export const Route = createLazyFileRoute("/columns/_layout/groups/$id")({
export function Screen() { export function Screen() {
const group = Route.useLoaderData(); const group = Route.useLoaderData();
const params = Route.useParams(); const params = Route.useParams();
const { queryClient } = Route.useRouteContext();
const { const {
data, data,
@@ -84,16 +82,6 @@ export function Screen() {
[data], [data],
); );
useEffect(() => {
const unlisten = listen("synchronized", async () => {
await queryClient.invalidateQueries({ queryKey: ["groups", params.id] });
});
return () => {
unlisten.then((f) => f());
};
}, []);
return ( return (
<ScrollArea.Root <ScrollArea.Root
type={"scroll"} type={"scroll"}

View File

@@ -7,8 +7,7 @@ import { ArrowDown } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event"; import { useCallback, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/columns/_layout/interests/$id")({ export const Route = createLazyFileRoute("/columns/_layout/interests/$id")({
@@ -18,7 +17,6 @@ export const Route = createLazyFileRoute("/columns/_layout/interests/$id")({
export function Screen() { export function Screen() {
const hashtags = Route.useLoaderData(); const hashtags = Route.useLoaderData();
const params = Route.useParams(); const params = Route.useParams();
const { queryClient } = Route.useRouteContext();
const { const {
data, data,
@@ -84,18 +82,6 @@ export function Screen() {
[data], [data],
); );
useEffect(() => {
const unlisten = listen("synchronized", async () => {
await queryClient.invalidateQueries({
queryKey: ["hashtags", params.id],
});
});
return () => {
unlisten.then((f) => f());
};
}, []);
return ( return (
<ScrollArea.Root <ScrollArea.Root
type={"scroll"} type={"scroll"}

View File

@@ -7,7 +7,6 @@ import { ArrowDown, ArrowUp } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { type InfiniteData, useInfiniteQuery } from "@tanstack/react-query"; import { type InfiniteData, useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { import {
memo, memo,
@@ -30,7 +29,6 @@ export const Route = createLazyFileRoute("/columns/_layout/newsfeed")({
export function Screen() { export function Screen() {
const contacts = Route.useLoaderData(); const contacts = Route.useLoaderData();
const { queryClient } = Route.useRouteContext();
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { const {
data, data,
@@ -95,16 +93,6 @@ export function Screen() {
[data], [data],
); );
useEffect(() => {
const unlisten = listen("synchronized", async () => {
await queryClient.invalidateQueries({ queryKey: [label, account] });
});
return () => {
unlisten.then((f) => f());
};
}, []);
return ( return (
<ScrollArea.Root <ScrollArea.Root
type={"scroll"} type={"scroll"}

View File

@@ -1,3 +1,4 @@
import { commands } from "@/commands.gen";
import { Frame, Spinner } from "@/components"; import { Frame, Spinner } from "@/components";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
@@ -21,13 +22,19 @@ function Screen() {
const search = Route.useSearch(); const search = Route.useSearch();
useEffect(() => { useEffect(() => {
const unlisten = listen("synchronized", () => { const unlisten = listen("neg_synchronized", async () => {
navigate({ const status = await commands.createSyncFile(search.account);
to: "/$account/home",
// @ts-ignore, this is tanstack router bug if (status) {
params: { account: search.account }, navigate({
replace: true, to: "/$account/home",
}); // @ts-ignore, this is tanstack router bug
params: { account: search.account },
replace: true,
});
} else {
throw new Error("System error.");
}
}); });
return () => { return () => {
@@ -43,7 +50,7 @@ function Screen() {
> >
<Spinner /> <Spinner />
<p className="text-sm text-neutral-600 dark:text-neutral-40"> <p className="text-sm text-neutral-600 dark:text-neutral-40">
Fetching necessary data for the first time login... Syncing all necessary data for the first time login...
</p> </p>
</Frame> </Frame>
</div> </div>

View File

@@ -4,11 +4,22 @@ import { useQuery } from "@tanstack/react-query";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { LumeEvent } from "../event"; import { LumeEvent } from "../event";
export function useEvent(id: string) { export function useEvent(id: string, repost?: string) {
const { isLoading, isError, error, data } = useQuery({ const { isLoading, isError, error, data } = useQuery({
queryKey: ["event", id], queryKey: ["event", id],
queryFn: async () => { queryFn: async () => {
try { try {
if (repost?.length) {
const nostrEvent: NostrEvent = JSON.parse(repost);
const res = await commands.getEventMeta(nostrEvent.content);
if (res.status === "ok") {
nostrEvent.meta = res.data;
}
return new LumeEvent(nostrEvent);
}
// Validate ID // Validate ID
let normalizeId: string = id let normalizeId: string = id
.replace("nostr:", "") .replace("nostr:", "")