Files
lume/packages/ark/src/ark.ts
2024-01-18 09:41:53 +07:00

618 lines
14 KiB
TypeScript

import { Account, type NDKEventWithReplies, type NIP05 } from "@lume/types";
import NDK, {
NDKEvent,
NDKFilter,
NDKKind,
NDKNip46Signer,
NDKPrivateKeySigner,
NDKRelay,
NDKSubscriptionCacheUsage,
NDKTag,
NDKUser,
NostrEvent,
} from "@nostr-dev-kit/ndk";
import { ndkAdapter } from "@nostr-fetch/adapter-ndk";
import { open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
import { fetch } from "@tauri-apps/plugin-http";
import { NostrFetcher, normalizeRelayUrl } from "nostr-fetch";
import { nip19 } from "nostr-tools";
export class Ark {
public ndk: NDK;
public account: Account;
constructor({
ndk,
account,
}: {
ndk: NDK;
account: Account;
}) {
this.ndk = ndk;
this.account = account;
}
public async connectDepot() {
return this.ndk.addExplicitRelay(
new NDKRelay(normalizeRelayUrl("ws://localhost:6090")),
undefined,
true,
);
}
public updateNostrSigner({
signer,
}: { signer: NDKNip46Signer | NDKPrivateKeySigner }) {
this.ndk.signer = signer;
return this.ndk.signer;
}
public subscribe({
filter,
closeOnEose = false,
cb,
}: {
filter: NDKFilter;
closeOnEose: boolean;
cb: (event: NDKEvent) => void;
}) {
const sub = this.ndk.subscribe(filter, { closeOnEose });
sub.addListener("event", (event: NDKEvent) => cb(event));
return sub;
}
public async createEvent({
kind,
tags,
content,
rootReplyTo = undefined,
replyTo = undefined,
}: {
kind: NDKKind | number;
tags: NDKTag[];
content?: string;
rootReplyTo?: string;
replyTo?: string;
}) {
try {
const event = new NDKEvent(this.ndk);
if (content) event.content = content;
event.kind = kind;
event.tags = tags;
if (rootReplyTo) {
const rootEvent = await this.ndk.fetchEvent(rootReplyTo);
if (rootEvent) event.tag(rootEvent, "root");
}
if (replyTo) {
const replyEvent = await this.ndk.fetchEvent(replyTo);
if (replyEvent) event.tag(replyEvent, "reply");
}
const publish = await event.publish();
if (!publish) throw new Error("Failed to publish event");
return {
id: event.id,
seens: [...publish.values()].map((item) => item.url),
};
} catch (e) {
throw new Error(e);
}
}
public getCleanPubkey(pubkey: string) {
try {
let hexstring = pubkey
.replace("nostr:", "")
.split("'")[0]
.split(".")[0]
.split(",")[0]
.split("?")[0];
if (
hexstring.startsWith("npub1") ||
hexstring.startsWith("nprofile1") ||
hexstring.startsWith("naddr1")
) {
const decoded = nip19.decode(hexstring);
if (decoded.type === "nprofile") hexstring = decoded.data.pubkey;
if (decoded.type === "npub") hexstring = decoded.data;
if (decoded.type === "naddr") hexstring = decoded.data.pubkey;
}
return hexstring;
} catch (e) {
console.log(e);
}
}
public async getUserProfile(pubkey?: string) {
try {
const currentUserPubkey = this.account.pubkey;
const hexstring = pubkey
? this.getCleanPubkey(pubkey)
: currentUserPubkey;
const user = this.ndk.getUser({
pubkey: hexstring,
});
const profile = await user.fetchProfile({
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
});
if (!profile) throw new Error("user not found");
return profile;
} catch {
throw new Error("user not found");
}
}
public async getUserContacts(pubkey?: string) {
try {
const currentUserPubkey = this.account.pubkey;
const hexstring = pubkey
? this.getCleanPubkey(pubkey)
: currentUserPubkey;
const user = this.ndk.getUser({
pubkey: hexstring,
});
const contacts = [...(await user.follows(undefined, false))].map(
(user) => user.pubkey,
);
if (!pubkey || pubkey === this.account.pubkey)
this.account.contacts = contacts;
return contacts;
} catch (e) {
console.error(e);
}
}
public async getUserRelays({ pubkey }: { pubkey?: string }) {
try {
const user = this.ndk.getUser({
pubkey: pubkey ? pubkey : this.account.pubkey,
});
return await user.relayList();
} catch (e) {
console.error(e);
}
}
public async newContactList({ tags }: { tags: NDKTag[] }) {
const publish = await this.createEvent({
kind: NDKKind.Contacts,
tags: tags,
});
if (publish) {
this.account.contacts = tags.map((item) => item[1]);
return publish;
}
}
public async createContact(pubkey: string) {
const user = this.ndk.getUser({ pubkey: this.account.pubkey });
const contacts = await user.follows();
return await user.follow(new NDKUser({ pubkey: pubkey }), contacts);
}
public async deleteContact(pubkey: string) {
const user = this.ndk.getUser({ pubkey: this.account.pubkey });
const contacts = await user.follows();
contacts.delete(new NDKUser({ pubkey: pubkey }));
const event = new NDKEvent(this.ndk);
event.content = "";
event.kind = NDKKind.Contacts;
event.tags = [...contacts].map((item) => [
"p",
item.pubkey,
item.relayUrls?.[0] || "",
"",
]);
return await event.publish();
}
public async getAllEvents({ filter }: { filter: NDKFilter }) {
const events = await this.ndk.fetchEvents(filter);
if (!events) return [];
return [...events];
}
public getCleanEventId(id: string) {
let eventId: string = id.replace("nostr:", "").split("'")[0].split(".")[0];
if (
eventId.startsWith("nevent1") ||
eventId.startsWith("note1") ||
eventId.startsWith("naddr1")
) {
const decode = nip19.decode(eventId);
if (decode.type === "nevent") eventId = decode.data.id;
if (decode.type === "note") eventId = decode.data;
}
return eventId;
}
public async getEventById(id: string) {
try {
const eventId = this.getCleanEventId(id);
return await this.ndk.fetchEvent(eventId);
} catch {
throw new Error("event not found");
}
}
public async getEventByFilter({
filter,
cache,
}: { filter: NDKFilter; cache?: NDKSubscriptionCacheUsage }) {
const event = await this.ndk.fetchEvent(filter, {
cacheUsage: cache || NDKSubscriptionCacheUsage.CACHE_FIRST,
});
if (!event) return null;
return event;
}
public getEventThread({
content,
tags,
}: { content: string; tags: NDKTag[] }) {
let rootEventId: string = null;
let replyEventId: string = null;
if (content.includes("nostr:note1") || content.includes("nostr:nevent1"))
return null;
const events = tags.filter((el) => el[0] === "e");
if (!events.length) return null;
if (events.length === 1)
return {
rootEventId: events[0][1],
replyEventId: null,
};
if (events.length > 1) {
rootEventId = events.find((el) => el[3] === "root")?.[1];
replyEventId = events.find((el) => el[3] === "reply")?.[1];
if (!rootEventId && !replyEventId) {
rootEventId = events[0][1];
replyEventId = events[1][1];
}
}
return {
rootEventId,
replyEventId,
};
}
public async getThreads(id: string) {
const eventId = this.getCleanEventId(id);
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(this.ndk));
const relayUrls = [...this.ndk.pool.relays.values()].map(
(item) => item.url,
);
try {
const rawEvents = (await fetcher.fetchAllEvents(
relayUrls,
{
kinds: [NDKKind.Text],
"#e": [eventId],
},
{ since: 0 },
{ sort: true },
)) as unknown as NostrEvent[];
const events = rawEvents.map(
(event) => new NDKEvent(this.ndk, event),
) as NDKEvent[] as NDKEventWithReplies[];
if (events.length > 0) {
const replies = new Set();
for (const event of events) {
const tags = event.tags.filter((el) => el[0] === "e" && el[1] !== id);
if (tags.length > 0) {
for (const tag of tags) {
const rootIndex = events.findIndex((el) => el.id === tag[1]);
if (rootIndex !== -1) {
const rootEvent = events[rootIndex];
if (rootEvent?.replies) {
rootEvent.replies.push(event);
} else {
rootEvent.replies = [event];
}
replies.add(event.id);
}
}
}
}
const cleanEvents = events.filter((ev) => !replies.has(ev.id));
return cleanEvents;
}
return events;
} catch (e) {
console.log(e);
} finally {
fetcher.shutdown();
}
}
public async getAllRelaysFromContacts({ signal }: { signal: AbortSignal }) {
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(this.ndk));
const connectedRelays = this.ndk.pool
.connectedRelays()
.map((item) => item.url);
try {
const relayMap = new Map<string, string[]>();
const relayEvents = fetcher.fetchLatestEventsPerAuthor(
{
authors: this.account.contacts,
relayUrls: connectedRelays,
},
{ kinds: [NDKKind.RelayList] },
1,
{ abortSignal: signal },
);
console.log(relayEvents);
for await (const { author, events } of relayEvents) {
if (events.length) {
const relayTags = events[0].tags.filter((item) => item[0] === "r");
for (const tag of relayTags) {
const item = relayMap.get(tag[1]);
if (item?.length) {
item.push(author);
} else {
relayMap.set(tag[1], [author]);
}
}
}
}
return relayMap;
} catch (e) {
console.log(e);
} finally {
fetcher.shutdown();
}
}
public async getInfiniteEvents({
filter,
limit,
pageParam = 0,
signal = undefined,
dedup = true,
}: {
filter: NDKFilter;
limit: number;
pageParam?: number;
signal?: AbortSignal;
dedup?: boolean;
}) {
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(this.ndk));
const relayUrls = [...this.ndk.pool.relays.values()].map(
(item) => item.url,
);
const seenIds = new Set<string>();
const dedupQueue = new Set<string>();
try {
const events = await fetcher.fetchLatestEvents(relayUrls, filter, limit, {
asOf: pageParam === 0 ? undefined : pageParam,
abortSignal: signal,
});
const ndkEvents = events.map((event) => {
return new NDKEvent(this.ndk, event);
});
if (dedup) {
for (const event of ndkEvents) {
const tags = event.tags
.filter((el) => el[0] === "e")
?.map((item) => item[1]);
if (tags.length) {
for (const tag of tags) {
if (seenIds.has(tag)) {
dedupQueue.add(event.id);
break;
}
seenIds.add(tag);
}
}
}
return ndkEvents
.filter((event) => !dedupQueue.has(event.id))
.sort((a, b) => b.created_at - a.created_at);
}
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
} catch (e) {
console.log(e);
} finally {
fetcher.shutdown();
}
}
public async getRelayEvents({
relayUrl,
filter,
limit,
pageParam = 0,
signal = undefined,
}: {
relayUrl: string;
filter: NDKFilter;
limit: number;
pageParam?: number;
signal?: AbortSignal;
dedup?: boolean;
}) {
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(this.ndk));
try {
const events = await fetcher.fetchLatestEvents(
[normalizeRelayUrl(relayUrl)],
filter,
limit,
{
asOf: pageParam === 0 ? undefined : pageParam,
abortSignal: signal,
},
);
const ndkEvents = events.map((event) => {
return new NDKEvent(this.ndk, event);
});
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
} catch (e) {
console.log(e);
} finally {
fetcher.shutdown();
}
}
/**
* Upload media file to nostr.build
* @todo support multiple backends
*/
public async upload({ fileExts }: { fileExts?: string[] }) {
const defaultExts = ["png", "jpeg", "jpg", "gif"].concat(fileExts);
const selected = await open({
multiple: false,
filters: [
{
name: "Image",
extensions: defaultExts,
},
],
});
if (!selected) return null;
const file = await readFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append("fileToUpload", blob);
data.append("submit", "Upload Image");
const res = await fetch("https://nostr.build/api/v2/upload/files", {
method: "POST",
body: data,
});
if (!res.ok) return null;
const json = await res.json();
const content = json.data[0];
return content.url as string;
}
public async validateNIP05({
pubkey,
nip05,
signal,
}: {
pubkey: string;
nip05: string;
signal?: AbortSignal;
}) {
const localPath = nip05.split("@")[0];
const service = nip05.split("@")[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
const res = await fetch(verifyURL, {
method: "GET",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
signal,
});
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
const data: NIP05 = await res.json();
if (!data.names) return false;
if (data.names[localPath.toLowerCase()] === pubkey) return true;
if (data.names[localPath] === pubkey) return true;
return false;
}
public async getAppRecommend({
unknownKind,
author,
}: { unknownKind: string; author?: string }) {
const event = await this.ndk.fetchEvent({
kinds: [NDKKind.AppRecommendation],
"#d": [unknownKind],
authors: this.account.contacts || [author],
});
if (event) return event.tags.filter((item) => item[0] !== "d");
const altEvent = await this.ndk.fetchEvent({
kinds: [NDKKind.AppHandler],
"#k": [unknownKind],
authors: this.account.contacts || [author],
});
if (altEvent) return altEvent.tags.filter((item) => item[0] !== "d");
return null;
}
public async getOAuthServices() {
const trusted: NDKEvent[] = [];
const services = await this.ndk.fetchEvents({
kinds: [NDKKind.AppHandler],
"#k": ["24133"],
});
for (const service of services) {
const nip05 = JSON.parse(service.content).nip05 as string;
try {
const validate = await this.validateNIP05({
pubkey: service.pubkey,
nip05,
});
if (validate) trusted.push(service);
} catch (e) {
console.log(e);
}
}
return trusted;
}
}