diff --git a/packages/app/src/Const.ts b/packages/app/src/Const.ts index acdaa7ef..6228798d 100644 --- a/packages/app/src/Const.ts +++ b/packages/app/src/Const.ts @@ -39,9 +39,9 @@ export const ProfileCacheExpire = 1_000 * 60 * 30; * Default bootstrap relays */ export const DefaultRelays = new Map([ - ["wss://relay.snort.social", { read: true, write: true }], - ["wss://nostr.wine", { read: true, write: false }], - ["wss://nos.lol", { read: true, write: true }], + ["wss://relay.snort.social/", { read: true, write: true }], + ["wss://nostr.wine/", { read: true, write: false }], + ["wss://nos.lol/", { read: true, write: true }], ]); /** diff --git a/packages/app/src/Element/DM.tsx b/packages/app/src/Element/DM.tsx index 72d2c1a0..9c4bf72f 100644 --- a/packages/app/src/Element/DM.tsx +++ b/packages/app/src/Element/DM.tsx @@ -28,10 +28,12 @@ export default function DM(props: DMProps) { const otherPubkey = isMe ? pubKey : unwrap(props.data.tags.find(a => a[0] === "p")?.[1]); async function decrypt() { - const decrypted = await publisher.decryptDm(props.data); - setContent(decrypted || ""); - if (!isMe) { - setLastReadDm(props.data.pubkey); + if (publisher) { + const decrypted = await publisher.decryptDm(props.data); + setContent(decrypted || ""); + if (!isMe) { + setLastReadDm(props.data.pubkey); + } } } diff --git a/packages/app/src/Element/FollowButton.tsx b/packages/app/src/Element/FollowButton.tsx index 159e2060..25259f09 100644 --- a/packages/app/src/Element/FollowButton.tsx +++ b/packages/app/src/Element/FollowButton.tsx @@ -14,18 +14,26 @@ export interface FollowButtonProps { } export default function FollowButton(props: FollowButtonProps) { const pubkey = parseId(props.pubkey); - const publiser = useEventPublisher(); - const isFollowing = useLogin().follows.item.includes(pubkey); + const publisher = useEventPublisher(); + const { follows, relays } = useLogin(); + const isFollowing = follows.item.includes(pubkey); const baseClassname = `${props.className} follow-button`; async function follow(pubkey: HexKey) { - const ev = await publiser.addFollow(pubkey); - publiser.broadcast(ev); + if (publisher) { + const ev = await publisher.contactList([pubkey, ...follows.item], relays.item); + publisher.broadcast(ev); + } } async function unfollow(pubkey: HexKey) { - const ev = await publiser.removeFollow(pubkey); - publiser.broadcast(ev); + if (publisher) { + const ev = await publisher.contactList( + follows.item.filter(a => a !== pubkey), + relays.item + ); + publisher.broadcast(ev); + } } return ( diff --git a/packages/app/src/Element/FollowListBase.tsx b/packages/app/src/Element/FollowListBase.tsx index 5eaa3e48..c072bfcf 100644 --- a/packages/app/src/Element/FollowListBase.tsx +++ b/packages/app/src/Element/FollowListBase.tsx @@ -6,6 +6,7 @@ import { HexKey } from "@snort/nostr"; import ProfilePreview from "Element/ProfilePreview"; import messages from "./messages"; +import useLogin from "Hooks/useLogin"; export interface FollowListBaseProps { pubkeys: HexKey[]; @@ -15,10 +16,13 @@ export interface FollowListBaseProps { } export default function FollowListBase({ pubkeys, title, showFollowAll, showAbout }: FollowListBaseProps) { const publisher = useEventPublisher(); + const { follows, relays } = useLogin(); async function followAll() { - const ev = await publisher.addFollow(pubkeys); - publisher.broadcast(ev); + if (publisher) { + const ev = await publisher.contactList([...pubkeys, ...follows.item], relays.item); + publisher.broadcast(ev); + } } return ( diff --git a/packages/app/src/Element/Nip5Service.tsx b/packages/app/src/Element/Nip5Service.tsx index b28e688a..4106d944 100644 --- a/packages/app/src/Element/Nip5Service.tsx +++ b/packages/app/src/Element/Nip5Service.tsx @@ -189,7 +189,7 @@ export default function Nip5Service(props: Nip05ServiceProps) { } async function updateProfile(handle: string, domain: string) { - if (user) { + if (user && publisher) { const nip05 = `${handle}@${domain}`; const newProfile = { ...user, diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Note.tsx index f34d2126..c8be3063 100644 --- a/packages/app/src/Element/Note.tsx +++ b/packages/app/src/Element/Note.tsx @@ -3,7 +3,7 @@ import React, { useMemo, useState, useLayoutEffect, ReactNode } from "react"; import { useNavigate, Link } from "react-router-dom"; import { useInView } from "react-intersection-observer"; import { useIntl, FormattedMessage } from "react-intl"; -import { TaggedRawEvent, HexKey, EventKind, NostrPrefix } from "@snort/nostr"; +import { TaggedRawEvent, HexKey, EventKind, NostrPrefix, Lists } from "@snort/nostr"; import useEventPublisher from "Feed/EventPublisher"; import Icon from "Icons/Icon"; @@ -132,27 +132,23 @@ export default function Note(props: NoteProps) { }; async function unpin(id: HexKey) { - if (options.canUnpin) { + if (options.canUnpin && publisher) { if (window.confirm(formatMessage(messages.ConfirmUnpin))) { const es = pinned.item.filter(e => e !== id); - const ev = await publisher.pinned(es); - if (ev) { - publisher.broadcast(ev); - setPinned(login, es, ev.created_at * 1000); - } + const ev = await publisher.noteList(es, Lists.Pinned); + publisher.broadcast(ev); + setPinned(login, es, ev.created_at * 1000); } } } async function unbookmark(id: HexKey) { - if (options.canUnbookmark) { + if (options.canUnbookmark && publisher) { if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) { const es = bookmarked.item.filter(e => e !== id); - const ev = await publisher.bookmarked(es); - if (ev) { - publisher.broadcast(ev); - setBookmarked(login, es, ev.created_at * 1000); - } + const ev = await publisher.noteList(es, Lists.Bookmarked); + publisher.broadcast(ev); + setBookmarked(login, es, ev.created_at * 1000); } } } diff --git a/packages/app/src/Element/NoteCreator.tsx b/packages/app/src/Element/NoteCreator.tsx index 581259ac..20658049 100644 --- a/packages/app/src/Element/NoteCreator.tsx +++ b/packages/app/src/Element/NoteCreator.tsx @@ -29,6 +29,7 @@ import { LNURL } from "LNURL"; import messages from "./messages"; import { ClipboardEventHandler, useState } from "react"; import Spinner from "Icons/Spinner"; +import { EventBuilder } from "System"; interface NotePreviewProps { note: TaggedRawEvent; @@ -64,7 +65,7 @@ export function NoteCreator() { const dispatch = useDispatch(); async function sendNote() { - if (note) { + if (note && publisher) { let extraTags: Array> | undefined; if (zapForward) { try { @@ -91,9 +92,12 @@ export function NoteCreator() { extraTags ??= []; extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a])); } - const ev = replyTo - ? await publisher.reply(replyTo, note, extraTags, kind) - : await publisher.note(note, extraTags, kind); + const hk = (eb: EventBuilder) => { + extraTags?.forEach(t => eb.tag(t)); + eb.kind(kind); + return eb; + }; + const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk); publisher.broadcast(ev); dispatch(reset()); } @@ -154,7 +158,7 @@ export function NoteCreator() { async function loadPreview() { if (preview) { dispatch(setPreview(undefined)); - } else { + } else if (publisher) { const tmpNote = await publisher.note(note); if (tmpNote) { dispatch(setPreview(tmpNote)); diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/NoteFooter.tsx index d4ece503..0bfa1007 100644 --- a/packages/app/src/Element/NoteFooter.tsx +++ b/packages/app/src/Element/NoteFooter.tsx @@ -3,7 +3,7 @@ import { useSelector, useDispatch } from "react-redux"; import { useIntl, FormattedMessage } from "react-intl"; import { Menu, MenuItem } from "@szhsin/react-menu"; import { useLongPress } from "use-long-press"; -import { TaggedRawEvent, HexKey, u256, encodeTLV, NostrPrefix } from "@snort/nostr"; +import { TaggedRawEvent, HexKey, u256, encodeTLV, NostrPrefix, Lists } from "@snort/nostr"; import Icon from "Icons/Icon"; import Spinner from "Icons/Spinner"; @@ -96,7 +96,7 @@ export default function NoteFooter(props: NoteFooterProps) { const dispatch = useDispatch(); const { formatMessage } = useIntl(); const login = useLogin(); - const { pinned, bookmarked, publicKey, preferences: prefs } = login; + const { pinned, bookmarked, publicKey, preferences: prefs, relays } = login; const { mute, block } = useModeration(); const author = useUserProfile(ev.pubkey); const publisher = useEventPublisher(); @@ -134,21 +134,21 @@ export default function NoteFooter(props: NoteFooterProps) { } async function react(content: string) { - if (!hasReacted(content)) { + if (!hasReacted(content) && publisher) { const evLike = await publisher.react(ev, content); publisher.broadcast(evLike); } } async function deleteEvent() { - if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) }))) { + if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) { const evDelete = await publisher.delete(ev.id); publisher.broadcast(evDelete); } } async function repost() { - if (!hasReposted()) { + if (!hasReposted() && publisher) { if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) { const evRepost = await publisher.repost(ev); publisher.broadcast(evRepost); @@ -196,7 +196,9 @@ export default function NoteFooter(props: NoteFooterProps) { await barrierZapper(async () => { const handler = new LNURL(lnurl); await handler.load(); - const zap = handler.canZap ? await publisher.zap(amount * 1000, key, id) : undefined; + + const zr = Object.keys(relays.item); + const zap = handler.canZap && publisher ? await publisher.zap(amount * 1000, key, zr, id) : undefined; const invoice = await handler.getInvoice(amount, undefined, zap); await wallet?.payInvoice(unwrap(invoice.pr)); }); @@ -320,18 +322,18 @@ export default function NoteFooter(props: NoteFooterProps) { } async function pin(id: HexKey) { - const es = [...pinned.item, id]; - const ev = await publisher.pinned(es); - if (ev) { + if (publisher) { + const es = [...pinned.item, id]; + const ev = await publisher.noteList(es, Lists.Pinned); publisher.broadcast(ev); setPinned(login, es, ev.created_at * 1000); } } async function bookmark(id: HexKey) { - const es = [...bookmarked.item, id]; - const ev = await publisher.bookmarked(es); - if (ev) { + if (publisher) { + const es = [...bookmarked.item, id]; + const ev = await publisher.noteList(es, Lists.Bookmarked); publisher.broadcast(ev); setBookmarked(login, es, ev.created_at * 1000); } diff --git a/packages/app/src/Element/Poll.tsx b/packages/app/src/Element/Poll.tsx index 562526da..0316c3f6 100644 --- a/packages/app/src/Element/Poll.tsx +++ b/packages/app/src/Element/Poll.tsx @@ -23,7 +23,7 @@ export default function Poll(props: PollProps) { const { formatMessage } = useIntl(); const publisher = useEventPublisher(); const { wallet } = useWallet(); - const { preferences: prefs, publicKey: myPubKey } = useLogin(); + const { preferences: prefs, publicKey: myPubKey, relays } = useLogin(); const pollerProfile = useUserProfile(props.ev.pubkey); const [error, setError] = useState(""); const [invoice, setInvoice] = useState(""); @@ -35,7 +35,7 @@ export default function Poll(props: PollProps) { const options = props.ev.tags.filter(a => a[0] === "poll_option").sort((a, b) => Number(a[1]) - Number(b[1])); async function zapVote(ev: React.MouseEvent, opt: number) { ev.stopPropagation(); - if (voting) return; + if (voting || !publisher) return; const amount = prefs.defaultZapAmount; try { @@ -53,17 +53,10 @@ export default function Poll(props: PollProps) { } setVoting(opt); - const zap = await publisher.zap(amount * 1000, props.ev.pubkey, props.ev.id, undefined, [ - ["poll_option", opt.toString()], - ]); - - if (!zap) { - throw new Error( - formatMessage({ - defaultMessage: "Can't create vote, maybe you're not logged in?", - }) - ); - } + const r = Object.keys(relays.item); + const zap = await publisher.zap(amount * 1000, props.ev.pubkey, r, props.ev.id, undefined, eb => + eb.tag(["poll_option", opt.toString()]) + ); const lnurl = props.ev.tags.find(a => a[0] === "zap")?.[1] || pollerProfile?.lud16 || pollerProfile?.lud06; if (!lnurl) return; diff --git a/packages/app/src/Element/SendSats.tsx b/packages/app/src/Element/SendSats.tsx index 287356a1..0fdcb993 100644 --- a/packages/app/src/Element/SendSats.tsx +++ b/packages/app/src/Element/SendSats.tsx @@ -13,10 +13,11 @@ import Copy from "Element/Copy"; import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL"; import { chunks, debounce } from "Util"; import { useWallet } from "Wallet"; -import { EventExt } from "System/EventExt"; +import useLogin from "Hooks/useLogin"; +import { generateRandomKey } from "Login"; +import { EventPublisher } from "System/EventPublisher"; import messages from "./messages"; -import useLogin from "Hooks/useLogin"; enum ZapType { PublicZap = 1, @@ -40,7 +41,8 @@ export interface SendSatsProps { export default function SendSats(props: SendSatsProps) { const onClose = props.onClose || (() => undefined); const { note, author, target } = props; - const defaultZapAmount = useLogin().preferences.defaultZapAmount; + const login = useLogin(); + const defaultZapAmount = login.preferences.defaultZapAmount; const amounts = [defaultZapAmount, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000]; const emojis: Record = { 1_000: "👍", @@ -118,22 +120,21 @@ export default function SendSats(props: SendSatsProps) { }; async function loadInvoice() { - if (!amount || !handler) return null; + if (!amount || !handler || !publisher) return null; let zap: RawEvent | undefined; if (author && zapType !== ZapType.NonZap) { - const ev = await publisher.zap(amount * 1000, author, note, comment); - if (ev) { - // replace sig for anon-zap - if (zapType === ZapType.AnonZap) { - const randomKey = publisher.newKey(); - console.debug("Generated new key for zap: ", randomKey); - ev.pubkey = randomKey.publicKey; - ev.id = ""; - ev.tags.push(["anon", ""]); - await EventExt.sign(ev, randomKey.privateKey); - } - zap = ev; + const relays = Object.keys(login.relays.item); + + // use random key for anon zaps + if (zapType === ZapType.AnonZap) { + const randomKey = generateRandomKey(); + console.debug("Generated new key for zap: ", randomKey); + + const publisher = new EventPublisher(randomKey.publicKey, randomKey.privateKey); + zap = await publisher.zap(amount * 1000, author, relays, note, comment); + } else { + zap = await publisher.zap(amount * 1000, author, relays, note, comment); } } diff --git a/packages/app/src/Feed/EventPublisher.ts b/packages/app/src/Feed/EventPublisher.ts index a5421a11..475ae29d 100644 --- a/packages/app/src/Feed/EventPublisher.ts +++ b/packages/app/src/Feed/EventPublisher.ts @@ -1,418 +1,12 @@ import { useMemo } from "react"; -import * as secp from "@noble/secp256k1"; -import { EventKind, RelaySettings, TaggedRawEvent, HexKey, RawEvent, u256, UserMetadata, Lists } from "@snort/nostr"; - -import { bech32ToHex, delay, unwrap } from "Util"; -import { DefaultRelays, HashtagRegex } from "Const"; -import { System } from "System"; -import { EventExt } from "System/EventExt"; import useLogin from "Hooks/useLogin"; - -declare global { - interface Window { - nostr: { - getPublicKey: () => Promise; - signEvent: (event: RawEvent) => Promise; - getRelays: () => Promise>; - nip04: { - encrypt: (pubkey: HexKey, content: string) => Promise; - decrypt: (pubkey: HexKey, content: string) => Promise; - }; - }; - } -} - -export type EventPublisher = ReturnType; +import { EventPublisher } from "System/EventPublisher"; export default function useEventPublisher() { - const { publicKey: pubKey, privateKey: privKey, follows, relays } = useLogin(); - const hasNip07 = "nostr" in window; - - async function signEvent(ev: RawEvent): Promise { - if (!pubKey) { - throw new Error("Cant sign events when logged out"); + const { publicKey, privateKey } = useLogin(); + return useMemo(() => { + if (publicKey) { + return new EventPublisher(publicKey, privateKey); } - - if (hasNip07 && !privKey) { - ev.id = await EventExt.createId(ev); - const tmpEv = (await barrierNip07(() => window.nostr.signEvent(ev))) as RawEvent; - ev.sig = tmpEv.sig; - return ev; - } else if (privKey) { - await EventExt.sign(ev, privKey); - } else { - console.warn("Count not sign event, no private keys available"); - } - return ev; - } - - function processContent(ev: RawEvent, msg: string) { - const replaceNpub = (match: string) => { - const npub = match.slice(1); - try { - const hex = bech32ToHex(npub); - const idx = ev.tags.length; - ev.tags.push(["p", hex]); - return `#[${idx}]`; - } catch (error) { - return match; - } - }; - const replaceNoteId = (match: string) => { - const noteId = match.slice(1); - try { - const hex = bech32ToHex(noteId); - const idx = ev.tags.length; - ev.tags.push(["e", hex, "", "mention"]); - return `#[${idx}]`; - } catch (error) { - return match; - } - }; - const replaceHashtag = (match: string) => { - const tag = match.slice(1); - ev.tags.push(["t", tag.toLowerCase()]); - return match; - }; - const content = msg - .replace(/@npub[a-z0-9]+/g, replaceNpub) - .replace(/@note1[acdefghjklmnpqrstuvwxyz023456789]{58}/g, replaceNoteId) - .replace(HashtagRegex, replaceHashtag); - ev.content = content; - } - - const ret = { - nip42Auth: async (challenge: string, relay: string) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.Auth); - ev.tags.push(["relay", relay]); - ev.tags.push(["challenge", challenge]); - return await signEvent(ev); - } - }, - broadcast: (ev: RawEvent | undefined) => { - if (ev) { - console.debug(ev); - System.BroadcastEvent(ev); - } - }, - /** - * Write event to DefaultRelays, this is important for profiles / relay lists to prevent bugs - * If a user removes all the DefaultRelays from their relay list and saves that relay list, - * When they open the site again we wont see that updated relay list and so it will appear to reset back to the previous state - */ - broadcastForBootstrap: (ev: RawEvent | undefined) => { - if (ev) { - for (const [k] of DefaultRelays) { - System.WriteOnceToRelay(k, ev); - } - } - }, - /** - * Write event to all given relays. - */ - broadcastAll: (ev: RawEvent | undefined, relays: string[]) => { - if (ev) { - for (const k of relays) { - System.WriteOnceToRelay(k, ev); - } - } - }, - muted: async (keys: HexKey[], priv: HexKey[]) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.PubkeyLists); - ev.tags.push(["d", Lists.Muted]); - keys.forEach(p => { - ev.tags.push(["p", p]); - }); - let content = ""; - if (priv.length > 0) { - const ps = priv.map(p => ["p", p]); - const plaintext = JSON.stringify(ps); - if (hasNip07 && !privKey) { - content = await barrierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext)); - } else if (privKey) { - content = await EventExt.encryptData(plaintext, pubKey, privKey); - } - } - ev.content = content; - return await signEvent(ev); - } - }, - pinned: async (notes: HexKey[]) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.NoteLists); - ev.tags.push(["d", Lists.Pinned]); - notes.forEach(n => { - ev.tags.push(["e", n]); - }); - return await signEvent(ev); - } - }, - bookmarked: async (notes: HexKey[]) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.NoteLists); - ev.tags.push(["d", Lists.Bookmarked]); - notes.forEach(n => { - ev.tags.push(["e", n]); - }); - return await signEvent(ev); - } - }, - tags: async (tags: string[]) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.TagLists); - ev.tags.push(["d", Lists.Followed]); - tags.forEach(t => { - ev.tags.push(["t", t]); - }); - return await signEvent(ev); - } - }, - metadata: async (obj: UserMetadata) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.SetMetadata); - ev.content = JSON.stringify(obj); - return await signEvent(ev); - } - }, - note: async (msg: string, extraTags?: Array>, kind?: EventKind) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, kind ?? EventKind.TextNote); - processContent(ev, msg); - if (extraTags) { - for (const et of extraTags) { - ev.tags.push(et); - } - } - return await signEvent(ev); - } - }, - /** - * Create a zap request event for a given target event/profile - * @param amount Millisats amout! - * @param author Author pubkey to tag in the zap - * @param note Note Id to tag in the zap - * @param msg Custom message to be included in the zap - * @param extraTags Any extra tags to include on the zap request event - * @returns - */ - zap: async (amount: number, author: HexKey, note?: HexKey, msg?: string, extraTags?: Array>) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.ZapRequest); - if (note) { - ev.tags.push(["e", note]); - } - ev.tags.push(["p", author]); - const relayTag = ["relays", ...Object.keys(relays).map(a => a.trim())]; - ev.tags.push(relayTag); - ev.tags.push(["amount", amount.toString()]); - ev.tags.push(...(extraTags ?? [])); - processContent(ev, msg || ""); - return await signEvent(ev); - } - }, - /** - * Reply to a note - */ - reply: async (replyTo: TaggedRawEvent, msg: string, extraTags?: Array>, kind?: EventKind) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, kind ?? EventKind.TextNote); - - const thread = EventExt.extractThread(ev); - if (thread) { - if (thread.root || thread.replyTo) { - ev.tags.push(["e", thread.root?.Event ?? thread.replyTo?.Event ?? "", "", "root"]); - } - ev.tags.push(["e", replyTo.id, replyTo.relays[0] ?? "", "reply"]); - - // dont tag self in replies - if (replyTo.pubkey !== pubKey) { - ev.tags.push(["p", replyTo.pubkey]); - } - - for (const pk of thread.pubKeys) { - if (pk === pubKey) { - continue; // dont tag self in replies - } - ev.tags.push(["p", pk]); - } - } else { - ev.tags.push(["e", replyTo.id, "", "reply"]); - // dont tag self in replies - if (replyTo.pubkey !== pubKey) { - ev.tags.push(["p", replyTo.pubkey]); - } - } - processContent(ev, msg); - if (extraTags) { - for (const et of extraTags) { - ev.tags.push(et); - } - } - return await signEvent(ev); - } - }, - react: async (evRef: RawEvent, content = "+") => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.Reaction); - ev.content = content; - ev.tags.push(["e", evRef.id]); - ev.tags.push(["p", evRef.pubkey]); - return await signEvent(ev); - } - }, - saveRelays: async () => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.ContactList); - ev.content = JSON.stringify(relays); - for (const pk of follows.item) { - ev.tags.push(["p", pk]); - } - - return await signEvent(ev); - } - }, - saveRelaysSettings: async () => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.Relays); - for (const [url, settings] of Object.entries(relays)) { - const rTag = ["r", url]; - if (settings.read && !settings.write) { - rTag.push("read"); - } - if (settings.write && !settings.read) { - rTag.push("write"); - } - ev.tags.push(rTag); - } - return await signEvent(ev); - } - }, - addFollow: async (pkAdd: HexKey | HexKey[], newRelays?: Record) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.ContactList); - ev.content = JSON.stringify(newRelays ?? relays); - const temp = new Set(follows.item); - if (Array.isArray(pkAdd)) { - pkAdd.forEach(a => temp.add(a)); - } else { - temp.add(pkAdd); - } - for (const pk of temp) { - if (pk.length !== 64) { - continue; - } - ev.tags.push(["p", pk.toLowerCase()]); - } - - return await signEvent(ev); - } - }, - removeFollow: async (pkRemove: HexKey) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.ContactList); - ev.content = JSON.stringify(relays); - for (const pk of follows.item) { - if (pk === pkRemove || pk.length !== 64) { - continue; - } - ev.tags.push(["p", pk]); - } - - return await signEvent(ev); - } - }, - /** - * Delete an event (NIP-09) - */ - delete: async (id: u256) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.Deletion); - ev.tags.push(["e", id]); - return await signEvent(ev); - } - }, - /** - * Repost a note (NIP-18) - */ - repost: async (note: TaggedRawEvent) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.Repost); - ev.tags.push(["e", note.id, ""]); - ev.tags.push(["p", note.pubkey]); - return await signEvent(ev); - } - }, - decryptDm: async (note: RawEvent): Promise => { - if (pubKey) { - if (note.pubkey !== pubKey && !note.tags.some(a => a[1] === pubKey)) { - return ""; - } - try { - const otherPubKey = note.pubkey === pubKey ? unwrap(note.tags.find(a => a[0] === "p")?.[1]) : note.pubkey; - if (hasNip07 && !privKey) { - return await barrierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.content)); - } else if (privKey) { - return await EventExt.decryptDm(note.content, privKey, otherPubKey); - } - } catch (e) { - console.error("Decryption failed", e); - return ""; - } - } - }, - sendDm: async (content: string, to: HexKey) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, EventKind.DirectMessage); - ev.content = content; - ev.tags.push(["p", to]); - - try { - if (hasNip07 && !privKey) { - const cx: string = await barrierNip07(() => window.nostr.nip04.encrypt(to, content)); - ev.content = cx; - return await signEvent(ev); - } else if (privKey) { - ev.content = await EventExt.encryptData(content, to, privKey); - return await signEvent(ev); - } - } catch (e) { - console.error("Encryption failed", e); - } - } - }, - newKey: () => { - const privKey = secp.utils.bytesToHex(secp.utils.randomPrivateKey()); - const pubKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(privKey)); - return { - privateKey: privKey, - publicKey: pubKey, - }; - }, - generic: async (content: string, kind: EventKind, tags?: Array>) => { - if (pubKey) { - const ev = EventExt.forPubKey(pubKey, kind); - ev.content = content; - ev.tags = tags ?? []; - return await signEvent(ev); - } - }, - }; - - return useMemo(() => ret, [pubKey, relays, follows]); + }, [publicKey, privateKey]); } - -let isNip07Busy = false; - -export const barrierNip07 = async (then: () => Promise): Promise => { - while (isNip07Busy) { - await delay(10); - } - isNip07Busy = true; - try { - return await then(); - } finally { - isNip07Busy = false; - } -}; diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index d01431f3..02062c7f 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -1,14 +1,13 @@ import { useEffect, useMemo } from "react"; -import { TaggedRawEvent, HexKey, Lists, EventKind } from "@snort/nostr"; +import { TaggedRawEvent, Lists, EventKind } from "@snort/nostr"; import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "Util"; import { makeNotification, sendNotification } from "Notifications"; -import useEventPublisher, { barrierNip07 } from "Feed/EventPublisher"; +import useEventPublisher from "Feed/EventPublisher"; import { getMutedKeys } from "Feed/MuteList"; import useModeration from "Hooks/useModeration"; import { FlatNoteStore, RequestBuilder } from "System"; import useRequestBuilder from "Hooks/useRequestBuilder"; -import { EventExt } from "System/EventExt"; import { DmCache } from "Cache"; import useLogin from "Hooks/useLogin"; import { addSubscription, setBlocked, setBookmarked, setFollows, setMuted, setPinned, setRelays, setTags } from "Login"; @@ -20,7 +19,7 @@ import { SubscriptionEvent } from "Subscription"; */ export default function useLoginFeed() { const login = useLogin(); - const { publicKey: pubKey, privateKey: privKey, readNotifications } = login; + const { publicKey: pubKey, readNotifications } = login; const { isMuted } = useModeration(); const publisher = useEventPublisher(); @@ -63,7 +62,7 @@ export default function useLoginFeed() { // update relays and follow lists useEffect(() => { - if (loginFeed.data) { + if (loginFeed.data && publisher) { const contactList = getNewest(loginFeed.data.filter(a => a.kind === EventKind.ContactList)); if (contactList) { if (contactList.content !== "" && contactList.content !== "{}") { @@ -93,7 +92,7 @@ export default function useLoginFeed() { }) ).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap))); } - }, [loginFeed]); + }, [loginFeed, publisher]); // send out notifications useEffect(() => { @@ -115,7 +114,8 @@ export default function useLoginFeed() { setMuted(login, muted.keys, muted.createdAt * 1000); if (muted.raw && (muted.raw?.content?.length ?? 0) > 0 && pubKey) { - decryptBlocked(muted.raw, pubKey, privKey) + publisher + ?.nip4Decrypt(muted.raw.content, pubKey) .then(plaintext => { try { const blocked = JSON.parse(plaintext); @@ -176,11 +176,3 @@ export default function useLoginFeed() { FollowsRelays.bulkSet(fRelays).catch(console.error); }, [dispatch, fRelays]);*/ } - -async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) { - if (pubKey && privKey) { - return await EventExt.decryptData(raw.content, privKey, pubKey); - } else { - return await barrierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content)); - } -} diff --git a/packages/app/src/Hooks/useModeration.tsx b/packages/app/src/Hooks/useModeration.tsx index f8467409..ce20ad48 100644 --- a/packages/app/src/Hooks/useModeration.tsx +++ b/packages/app/src/Hooks/useModeration.tsx @@ -10,14 +10,10 @@ export default function useModeration() { const publisher = useEventPublisher(); async function setMutedList(pub: HexKey[], priv: HexKey[]) { - try { + if (publisher) { const ev = await publisher.muted(pub, priv); - if (ev) { - publisher.broadcast(ev); - return ev.created_at * 1000; - } - } catch (error) { - console.debug("Couldn't change mute list"); + publisher.broadcast(ev); + return ev.created_at * 1000; } return 0; } diff --git a/packages/app/src/Login/Functions.ts b/packages/app/src/Login/Functions.ts index d0562e4c..498b7e51 100644 --- a/packages/app/src/Login/Functions.ts +++ b/packages/app/src/Login/Functions.ts @@ -2,11 +2,11 @@ import { HexKey, RelaySettings } from "@snort/nostr"; import * as secp from "@noble/secp256k1"; import { DefaultRelays, SnortPubKey } from "Const"; -import { EventPublisher } from "Feed/EventPublisher"; import { LoginStore, UserPreferences, LoginSession } from "Login"; -import { generateBip39Entropy, entropyToDerivedKey } from "nip6"; -import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unixNowMs } from "Util"; +import { generateBip39Entropy, entropyToPrivateKey } from "nip6"; +import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unixNowMs, unwrap } from "Util"; import { getCurrentSubscription, SubscriptionEvent } from "Subscription"; +import { EventPublisher } from "System/EventPublisher"; export function setRelays(state: LoginSession, relays: Record, createdAt: number) { if (state.relays.timestamp > createdAt) { @@ -55,10 +55,10 @@ export function clearEntropy(state: LoginSession) { /** * Generate a new key and login with this generated key */ -export async function generateNewLogin(publisher: EventPublisher) { +export async function generateNewLogin() { const ent = generateBip39Entropy(); - const entHex = secp.utils.bytesToHex(ent); - const newKeyHex = entropyToDerivedKey(ent); + const entropy = secp.utils.bytesToHex(ent); + const privateKey = entropyToPrivateKey(ent); let newRelays: Record = {}; try { @@ -66,7 +66,7 @@ export async function generateNewLogin(publisher: EventPublisher) { if (rsp.ok) { const online: string[] = await rsp.json(); const pickRandom = randomSample(online, 4); - const relayObjects = pickRandom.map(a => [a, { read: true, write: true }]); + const relayObjects = pickRandom.map(a => [unwrap(sanitizeRelayUrl(a)), { read: true, write: true }]); newRelays = { ...Object.fromEntries(relayObjects), ...Object.fromEntries(DefaultRelays.entries()), @@ -76,10 +76,21 @@ export async function generateNewLogin(publisher: EventPublisher) { console.warn(e); } - const ev = await publisher.addFollow([bech32ToHex(SnortPubKey), newKeyHex], newRelays); + const publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(privateKey)); + const publisher = new EventPublisher(publicKey, privateKey); + const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey], newRelays); publisher.broadcast(ev); - LoginStore.loginWithPrivateKey(newKeyHex, entHex); + LoginStore.loginWithPrivateKey(privateKey, entropy, newRelays); +} + +export function generateRandomKey() { + const privateKey = secp.utils.bytesToHex(secp.utils.randomPrivateKey()); + const publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(privateKey)); + return { + privateKey, + publicKey, + }; } export function setTags(state: LoginSession, tags: Array, ts: number) { diff --git a/packages/app/src/Login/MultiAccountStore.ts b/packages/app/src/Login/MultiAccountStore.ts index 57e62230..31850558 100644 --- a/packages/app/src/Login/MultiAccountStore.ts +++ b/packages/app/src/Login/MultiAccountStore.ts @@ -40,7 +40,7 @@ const LoggedOut = { }, latestNotification: 0, readNotifications: 0, - subscriptions: [] + subscriptions: [], } as LoginSession; const LegacyKeys = { PrivateKeyItem: "secret", @@ -94,20 +94,27 @@ export class MultiAccountStore extends ExternalStore { return newSession; } - loginWithPrivateKey(key: HexKey, entropy?: string) { + loginWithPrivateKey(key: HexKey, entropy?: string, relays?: Record) { const pubKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(key)); if (this.#accounts.has(pubKey)) { throw new Error("Already logged in with this pubkey"); } - this.#accounts.set(pubKey, { + const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries()); + const newSession = { ...LoggedOut, privateKey: key, publicKey: pubKey, generatedEntropy: entropy, + relays: { + item: initRelays, + timestamp: 1, + }, preferences: deepClone(DefaultPreferences), - } as LoginSession); + } as LoginSession; + this.#accounts.set(pubKey, newSession); this.#activeAccount = pubKey; this.#save(); + return newSession; } updateSession(s: LoginSession) { diff --git a/packages/app/src/Nip05/SnortServiceProvider.ts b/packages/app/src/Nip05/SnortServiceProvider.ts index fb603f3f..4fcc6d36 100644 --- a/packages/app/src/Nip05/SnortServiceProvider.ts +++ b/packages/app/src/Nip05/SnortServiceProvider.ts @@ -1,5 +1,5 @@ import { EventKind } from "@snort/nostr"; -import { EventPublisher } from "Feed/EventPublisher"; +import { EventPublisher } from "System/EventPublisher"; import { ServiceError, ServiceProvider } from "./ServiceProvider"; export interface ManageHandle { @@ -48,10 +48,12 @@ export default class SnortServiceProvider extends ServiceProvider { body?: unknown, headers?: { [key: string]: string } ): Promise { - const auth = await this.#publisher.generic("", EventKind.HttpAuthentication, [ - ["url", `${this.url}${path}`], - ["method", method ?? "GET"], - ]); + const auth = await this.#publisher.generic(eb => { + eb.kind(EventKind.HttpAuthentication); + eb.tag(["url", `${this.url}${path}`]); + eb.tag(["method", method ?? "GET"]); + return eb; + }); if (!auth) { return { error: "INVALID_TOKEN", diff --git a/packages/app/src/Pages/ChatPage.tsx b/packages/app/src/Pages/ChatPage.tsx index f70ad8e1..3603c148 100644 --- a/packages/app/src/Pages/ChatPage.tsx +++ b/packages/app/src/Pages/ChatPage.tsx @@ -41,9 +41,8 @@ export default function ChatPage() { }, [dmListRef.current?.scrollHeight]); async function sendDm() { - if (content) { + if (content && publisher) { const ev = await publisher.sendDm(content, id); - console.debug(ev); publisher.broadcast(ev); setContent(""); } diff --git a/packages/app/src/Pages/HashTagsPage.tsx b/packages/app/src/Pages/HashTagsPage.tsx index 2d9e39da..8dc27e06 100644 --- a/packages/app/src/Pages/HashTagsPage.tsx +++ b/packages/app/src/Pages/HashTagsPage.tsx @@ -17,8 +17,8 @@ const HashTagsPage = () => { const publisher = useEventPublisher(); async function followTags(ts: string[]) { - const ev = await publisher.tags(ts); - if (ev) { + if (publisher) { + const ev = await publisher.tags(ts); publisher.broadcast(ev); setTags(login, ts, ev.created_at * 1000); } diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index 3a1d4754..0036ed20 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -63,7 +63,9 @@ export default function Layout() { }, [location]); useEffect(() => { - System.HandleAuth = pub.nip42Auth; + if (pub) { + System.HandleAuth = pub.nip42Auth; + } }, [pub]); useEffect(() => { @@ -224,7 +226,14 @@ const AccountHeader = () => { {hasNotifications && } - {profile && navigate(profileLink(profile.pubkey))} />} + { + if (profile) { + navigate(profileLink(profile.pubkey)); + } + }} + /> ); }; diff --git a/packages/app/src/Pages/Login.tsx b/packages/app/src/Pages/Login.tsx index 204c49c8..0586586e 100644 --- a/packages/app/src/Pages/Login.tsx +++ b/packages/app/src/Pages/Login.tsx @@ -8,13 +8,12 @@ import { HexKey } from "@snort/nostr"; import { EmailRegex, MnemonicRegex } from "Const"; import { bech32ToHex, unwrap } from "Util"; -import { generateBip39Entropy, entropyToDerivedKey } from "nip6"; +import { generateBip39Entropy, entropyToPrivateKey } from "nip6"; import ZapButton from "Element/ZapButton"; import useImgProxy from "Hooks/useImgProxy"; import Icon from "Icons/Icon"; import useLogin from "Hooks/useLogin"; import { generateNewLogin, LoginStore } from "Login"; -import useEventPublisher from "Feed/EventPublisher"; import AsyncButton from "Element/AsyncButton"; import messages from "./messages"; @@ -68,7 +67,6 @@ export async function getNip05PubKey(addr: string): Promise { export default function LoginPage() { const navigate = useNavigate(); - const publisher = useEventPublisher(); const login = useLogin(); const [key, setKey] = useState(""); const [error, setError] = useState(""); @@ -117,7 +115,7 @@ export default function LoginPage() { throw new Error(insecureMsg); } const ent = generateBip39Entropy(key); - const keyHex = entropyToDerivedKey(ent); + const keyHex = entropyToPrivateKey(ent); LoginStore.loginWithPrivateKey(keyHex); } else if (secp.utils.isValidPrivateKey(key)) { if (!hasSubtleCrypto) { @@ -142,7 +140,7 @@ export default function LoginPage() { } async function makeRandomKey() { - await generateNewLogin(publisher); + await generateNewLogin(); navigate("/new"); } diff --git a/packages/app/src/Pages/new/NewUserFlow.tsx b/packages/app/src/Pages/new/NewUserFlow.tsx index ae1ac6db..f34cc2d2 100644 --- a/packages/app/src/Pages/new/NewUserFlow.tsx +++ b/packages/app/src/Pages/new/NewUserFlow.tsx @@ -68,7 +68,7 @@ const Extensions = () => { }; export default function NewUserFlow() { - const { publicKey, privateKey, generatedEntropy } = useLogin(); + const { publicKey, generatedEntropy } = useLogin(); const navigate = useNavigate(); return ( @@ -87,10 +87,6 @@ export default function NewUserFlow() { -

- -

-

diff --git a/packages/app/src/Pages/new/NewUsername.tsx b/packages/app/src/Pages/new/NewUsername.tsx index e604e5ac..f4699718 100644 --- a/packages/app/src/Pages/new/NewUsername.tsx +++ b/packages/app/src/Pages/new/NewUsername.tsx @@ -14,9 +14,8 @@ export default function NewUserName() { const navigate = useNavigate(); const onNext = async () => { - if (username.length > 0) { + if (username.length > 0 && publisher) { const ev = await publisher.metadata({ name: username }); - console.debug(ev); publisher.broadcast(ev); } navigate("/new/verify"); diff --git a/packages/app/src/Pages/settings/Profile.tsx b/packages/app/src/Pages/settings/Profile.tsx index 51f07e38..6b9b494a 100644 --- a/packages/app/src/Pages/settings/Profile.tsx +++ b/packages/app/src/Pages/settings/Profile.tsx @@ -76,13 +76,14 @@ export default function ProfileSettings(props: ProfileSettingsProps) { delete userCopy["zapService"]; console.debug(userCopy); - const ev = await publisher.metadata(userCopy); - console.debug(ev); - publisher.broadcast(ev); + if (publisher) { + const ev = await publisher.metadata(userCopy); + publisher.broadcast(ev); - const newProfile = mapEventToProfile(ev as TaggedRawEvent); - if (newProfile) { - await UserCache.set(newProfile); + const newProfile = mapEventToProfile(ev as TaggedRawEvent); + if (newProfile) { + await UserCache.set(newProfile); + } } } diff --git a/packages/app/src/Pages/settings/Relays.tsx b/packages/app/src/Pages/settings/Relays.tsx index fb451d1e..7cb7572e 100644 --- a/packages/app/src/Pages/settings/Relays.tsx +++ b/packages/app/src/Pages/settings/Relays.tsx @@ -5,11 +5,10 @@ import { randomSample, unixNowMs } from "Util"; import Relay from "Element/Relay"; import useEventPublisher from "Feed/EventPublisher"; import { System } from "System"; - -import messages from "./messages"; import useLogin from "Hooks/useLogin"; import { setRelays } from "Login"; +import messages from "./messages"; const RelaySettingsPage = () => { const publisher = useEventPublisher(); const login = useLogin(); @@ -21,16 +20,18 @@ const RelaySettingsPage = () => { }, [relays]); async function saveRelays() { - const ev = await publisher.saveRelays(); - publisher.broadcast(ev); - publisher.broadcastForBootstrap(ev); - try { - const onlineRelays = await fetch("https://api.nostr.watch/v1/online").then(r => r.json()); - const settingsEv = await publisher.saveRelaysSettings(); - const rs = Object.keys(relays).concat(randomSample(onlineRelays, 20)); - publisher.broadcastAll(settingsEv, rs); - } catch (error) { - console.error(error); + if (publisher) { + const ev = await publisher.contactList(login.follows.item, login.relays.item); + publisher.broadcast(ev); + publisher.broadcastForBootstrap(ev); + try { + const onlineRelays = await fetch("https://api.nostr.watch/v1/online").then(r => r.json()); + const relayList = await publisher.relayList(login.relays.item); + const rs = Object.keys(relays).concat(randomSample(onlineRelays, 20)); + publisher.broadcastAll(relayList, rs); + } catch (error) { + console.error(error); + } } } diff --git a/packages/app/src/Pages/settings/handle/LNAddress.tsx b/packages/app/src/Pages/settings/handle/LNAddress.tsx index 2f17f634..8f282eba 100644 --- a/packages/app/src/Pages/settings/handle/LNAddress.tsx +++ b/packages/app/src/Pages/settings/handle/LNAddress.tsx @@ -10,12 +10,13 @@ import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider"; export default function LNForwardAddress({ handle }: { handle: ManageHandle }) { const { formatMessage } = useIntl(); const publisher = useEventPublisher(); - const sp = new SnortServiceProvider(publisher, `${ApiHost}/api/v1/n5sp`); const [newAddress, setNewAddress] = useState(handle.lnAddress ?? ""); const [error, setError] = useState(""); async function startUpdate() { + if (!publisher) return; + const req = { lnAddress: newAddress, }; @@ -33,6 +34,7 @@ export default function LNForwardAddress({ handle }: { handle: ManageHandle }) { return; } + const sp = new SnortServiceProvider(publisher, `${ApiHost}/api/v1/n5sp`); const rsp = await sp.patch(handle.id, req); if ("error" in rsp) { setError(rsp.error); diff --git a/packages/app/src/Pages/settings/handle/ListHandles.tsx b/packages/app/src/Pages/settings/handle/ListHandles.tsx index 612f4895..e1c00fbc 100644 --- a/packages/app/src/Pages/settings/handle/ListHandles.tsx +++ b/packages/app/src/Pages/settings/handle/ListHandles.tsx @@ -10,13 +10,14 @@ export default function ListHandles() { const navigate = useNavigate(); const publisher = useEventPublisher(); const [handles, setHandles] = useState>([]); - const sp = new SnortServiceProvider(publisher, `${ApiHost}/api/v1/n5sp`); useEffect(() => { loadHandles().catch(console.error); - }, []); + }, [publisher]); async function loadHandles() { + if (!publisher) return; + const sp = new SnortServiceProvider(publisher, `${ApiHost}/api/v1/n5sp`); const list = await sp.list(); setHandles(list as Array); } diff --git a/packages/app/src/Pages/settings/handle/TransferHandle.tsx b/packages/app/src/Pages/settings/handle/TransferHandle.tsx index d18712fc..bbe39ff1 100644 --- a/packages/app/src/Pages/settings/handle/TransferHandle.tsx +++ b/packages/app/src/Pages/settings/handle/TransferHandle.tsx @@ -11,13 +11,13 @@ export default function TransferHandle({ handle }: { handle: ManageHandle }) { const publisher = useEventPublisher(); const navigate = useNavigate(); const { formatMessage } = useIntl(); - const sp = new SnortServiceProvider(publisher, `${ApiHost}/api/v1/n5sp`); const [newKey, setNewKey] = useState(""); const [error, setError] = useState>([]); async function startTransfer() { - if (!newKey) return; + if (!newKey || !publisher) return; + const sp = new SnortServiceProvider(publisher, `${ApiHost}/api/v1/n5sp`); setError([]); const rsp = await sp.transfer(handle.id, newKey); if ("error" in rsp) { diff --git a/packages/app/src/SnortApi.ts b/packages/app/src/SnortApi.ts index c168c7dd..88c6aa64 100644 --- a/packages/app/src/SnortApi.ts +++ b/packages/app/src/SnortApi.ts @@ -1,6 +1,7 @@ +import { EventKind } from "@snort/nostr"; import { ApiHost } from "Const"; -import { EventPublisher } from "Feed/EventPublisher"; import { SubscriptionType } from "Subscription"; +import { EventPublisher } from "System/EventPublisher"; export interface RevenueToday { donations: number; @@ -61,10 +62,12 @@ export default class SnortApi { if (!this.#publisher) { throw new Error("Publisher not set"); } - const auth = await this.#publisher.generic("", 27_235, [ - ["url", `${this.#url}${path}`], - ["method", method ?? "GET"], - ]); + const auth = await this.#publisher.generic(eb => { + eb.kind(EventKind.HttpAuthentication); + eb.tag(["url", `${this.#url}${path}`]); + eb.tag(["method", method ?? "GET"]); + return eb; + }); if (!auth) { throw new Error("Failed to create auth event"); } diff --git a/packages/app/src/System/EventBuilder.ts b/packages/app/src/System/EventBuilder.ts new file mode 100644 index 00000000..0294328a --- /dev/null +++ b/packages/app/src/System/EventBuilder.ts @@ -0,0 +1,101 @@ +import { EventKind, HexKey, NostrPrefix, RawEvent } from "@snort/nostr"; +import { HashtagRegex } from "Const"; +import { parseNostrLink, unixNow } from "Util"; +import { EventExt } from "./EventExt"; + +export class EventBuilder { + #kind?: EventKind; + #content?: string; + #createdAt?: number; + #pubkey?: string; + #tags: Array> = []; + + kind(k: EventKind) { + this.#kind = k; + return this; + } + + content(c: string) { + this.#content = c; + return this; + } + + createdAt(n: number) { + this.#createdAt = n; + return this; + } + + pubKey(k: string) { + this.#pubkey = k; + return this; + } + + tag(t: Array) { + this.#tags.push(t); + return this; + } + + /** + * Extract mentions + */ + processContent() { + if (this.#content) { + this.#content = this.#content + .replace(/@n[pub|profile|event|ote|addr|]1[acdefghjklmnpqrstuvwxyz023456789]+/g, m => this.#replaceMention(m)) + .replace(HashtagRegex, m => this.#replaceHashtag(m)); + } + return this; + } + + build() { + this.#validate(); + const ev = { + id: "", + pubkey: this.#pubkey ?? "", + content: this.#content ?? "", + kind: this.#kind, + created_at: this.#createdAt ?? unixNow(), + tags: this.#tags, + } as RawEvent; + ev.id = EventExt.createId(ev); + return ev; + } + + /** + * Build and sign event + * @param pk Private key to sign event with + */ + async buildAndSign(pk: HexKey) { + const ev = this.build(); + await EventExt.sign(ev, pk); + return ev; + } + + #validate() { + if (!this.#kind) { + throw new Error("Kind must be set"); + } + if (!this.#pubkey) { + throw new Error("Pubkey must be set"); + } + } + + #replaceMention(match: string) { + const npub = match.slice(1); + const link = parseNostrLink(npub); + if (link) { + if (link.type === NostrPrefix.Profile || link.type === NostrPrefix.PublicKey) { + this.tag(["p", link.id]); + } + return `nostr:${link.encode()}`; + } else { + return match; + } + } + + #replaceHashtag(match: string) { + const tag = match.slice(1); + this.tag(["t", tag.toLowerCase()]); + return match; + } +} diff --git a/packages/app/src/System/EventExt.ts b/packages/app/src/System/EventExt.ts index b1b8d189..50c00589 100644 --- a/packages/app/src/System/EventExt.ts +++ b/packages/app/src/System/EventExt.ts @@ -26,7 +26,7 @@ export abstract class EventExt { * Sign this message with a private key */ static async sign(e: RawEvent, key: HexKey) { - e.id = await this.createId(e); + e.id = this.createId(e); const sig = await secp.schnorr.sign(e.id, key); e.sig = secp.utils.bytesToHex(sig); @@ -40,12 +40,12 @@ export abstract class EventExt { * @returns True if valid signature */ static async verify(e: RawEvent) { - const id = await this.createId(e); + const id = this.createId(e); const result = await secp.schnorr.verify(e.sig, id, e.pubkey); return result; } - static async createId(e: RawEvent) { + static createId(e: RawEvent) { const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content]; const hash = sha256(JSON.stringify(payload)); diff --git a/packages/app/src/System/EventPublisher.ts b/packages/app/src/System/EventPublisher.ts new file mode 100644 index 00000000..712c91bb --- /dev/null +++ b/packages/app/src/System/EventPublisher.ts @@ -0,0 +1,346 @@ +import * as secp from "@noble/secp256k1"; +import { + EventKind, + FullRelaySettings, + HexKey, + Lists, + RawEvent, + RelaySettings, + TaggedRawEvent, + u256, + UserMetadata, +} from "@snort/nostr"; + +import { DefaultRelays } from "Const"; +import { System } from "System"; +import { unwrap } from "Util"; +import { EventBuilder } from "./EventBuilder"; +import { EventExt } from "./EventExt"; + +declare global { + interface Window { + nostr: { + getPublicKey: () => Promise; + signEvent: (event: RawEvent) => Promise; + getRelays: () => Promise>; + nip04: { + encrypt: (pubkey: HexKey, content: string) => Promise; + decrypt: (pubkey: HexKey, content: string) => Promise; + }; + }; + } +} + +interface Nip7QueueItem { + next: () => Promise; + resolve(v: unknown): void; + reject(e: unknown): void; +} + +const Nip7QueueDelay = 200; +const Nip7Queue: Array = []; +async function processQueue() { + while (Nip7Queue.length > 0) { + const v = Nip7Queue.shift(); + if (v) { + try { + const ret = await v.next(); + v.resolve(ret); + } catch (e) { + v.reject(e); + } + } + } + setTimeout(processQueue, Nip7QueueDelay); +} +processQueue(); + +export const barrierNip07 = async (then: () => Promise): Promise => { + return new Promise((resolve, reject) => { + Nip7Queue.push({ + next: then, + resolve, + reject, + }); + }); +}; + +export type EventBuilderHook = (ev: EventBuilder) => EventBuilder; + +export class EventPublisher { + #pubKey: string; + #privateKey?: string; + #hasNip07 = "nostr" in window; + + constructor(pubKey: string, privKey?: string) { + if (privKey) { + this.#privateKey = privKey; + this.#pubKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(privKey)); + } else { + this.#pubKey = pubKey; + } + } + + #eb(k: EventKind) { + const eb = new EventBuilder(); + return eb.pubKey(this.#pubKey).kind(k); + } + + async #sign(eb: EventBuilder) { + if (this.#hasNip07 && !this.#privateKey) { + const nip7PubKey = await barrierNip07(() => window.nostr.getPublicKey()); + if (nip7PubKey !== this.#pubKey) { + throw new Error("Can't sign event, NIP-07 pubkey does not match"); + } + const ev = eb.build(); + return await barrierNip07(() => window.nostr.signEvent(ev)); + } else if (this.#privateKey) { + return await eb.buildAndSign(this.#privateKey); + } else { + throw new Error("Can't sign event, no private keys available"); + } + } + + async nip4Encrypt(content: string, key: HexKey) { + if (this.#hasNip07 && !this.#privateKey) { + const nip7PubKey = await barrierNip07(() => window.nostr.getPublicKey()); + if (nip7PubKey !== this.#pubKey) { + throw new Error("Can't encrypt content, NIP-07 pubkey does not match"); + } + return await barrierNip07(() => window.nostr.nip04.encrypt(key, content)); + } else if (this.#privateKey) { + return await EventExt.encryptData(content, key, this.#privateKey); + } else { + throw new Error("Can't encrypt content, no private keys available"); + } + } + + async nip4Decrypt(content: string, otherKey: HexKey) { + if (this.#hasNip07 && !this.#privateKey) { + return await barrierNip07(() => window.nostr.nip04.decrypt(otherKey, content)); + } else if (this.#privateKey) { + return await EventExt.decryptDm(content, this.#privateKey, otherKey); + } else { + throw new Error("Can't decrypt content, no private keys available"); + } + } + + async nip42Auth(challenge: string, relay: string) { + const eb = this.#eb(EventKind.Auth); + eb.tag(["relay", relay]); + eb.tag(["challenge", challenge]); + return await this.#sign(eb); + } + + broadcast(ev: RawEvent) { + console.debug(ev); + System.BroadcastEvent(ev); + } + + /** + * Write event to DefaultRelays, this is important for profiles / relay lists to prevent bugs + * If a user removes all the DefaultRelays from their relay list and saves that relay list, + * When they open the site again we wont see that updated relay list and so it will appear to reset back to the previous state + */ + broadcastForBootstrap(ev: RawEvent) { + for (const [k] of DefaultRelays) { + System.WriteOnceToRelay(k, ev); + } + } + + /** + * Write event to all given relays. + */ + broadcastAll(ev: RawEvent, relays: string[]) { + for (const k of relays) { + System.WriteOnceToRelay(k, ev); + } + } + + async muted(keys: HexKey[], priv: HexKey[]) { + const eb = this.#eb(EventKind.PubkeyLists); + + eb.tag(["d", Lists.Muted]); + keys.forEach(p => { + eb.tag(["p", p]); + }); + if (priv.length > 0) { + const ps = priv.map(p => ["p", p]); + const plaintext = JSON.stringify(ps); + eb.content(await this.nip4Encrypt(plaintext, this.#pubKey)); + } + return await this.#sign(eb); + } + + async noteList(notes: u256[], list: Lists) { + const eb = this.#eb(EventKind.NoteLists); + eb.tag(["d", list]); + notes.forEach(n => { + eb.tag(["e", n]); + }); + return await this.#sign(eb); + } + + async tags(tags: string[]) { + const eb = this.#eb(EventKind.TagLists); + eb.tag(["d", Lists.Followed]); + tags.forEach(t => { + eb.tag(["t", t]); + }); + return await this.#sign(eb); + } + + async metadata(obj: UserMetadata) { + const eb = this.#eb(EventKind.SetMetadata); + eb.content(JSON.stringify(obj)); + return await this.#sign(eb); + } + + /** + * Create a basic text note + */ + async note(msg: string, fnExtra?: EventBuilderHook) { + const eb = this.#eb(EventKind.TextNote); + eb.content(msg); + eb.processContent(); + fnExtra?.(eb); + return await this.#sign(eb); + } + + /** + * Create a zap request event for a given target event/profile + * @param amount Millisats amout! + * @param author Author pubkey to tag in the zap + * @param note Note Id to tag in the zap + * @param msg Custom message to be included in the zap + */ + async zap( + amount: number, + author: HexKey, + relays: Array, + note?: HexKey, + msg?: string, + fnExtra?: EventBuilderHook + ) { + const eb = this.#eb(EventKind.ZapRequest); + eb.content(msg ?? ""); + if (note) { + eb.tag(["e", note]); + } + eb.tag(["p", author]); + eb.tag(["relays", ...relays.map(a => a.trim())]); + eb.tag(["amount", amount.toString()]); + eb.processContent(); + fnExtra?.(eb); + return await this.#sign(eb); + } + + /** + * Reply to a note + */ + async reply(replyTo: TaggedRawEvent, msg: string, fnExtra?: EventBuilderHook) { + const eb = this.#eb(EventKind.TextNote); + eb.content(msg); + + const thread = EventExt.extractThread(replyTo); + if (thread) { + if (thread.root || thread.replyTo) { + eb.tag(["e", thread.root?.Event ?? thread.replyTo?.Event ?? "", "", "root"]); + } + eb.tag(["e", replyTo.id, replyTo.relays[0] ?? "", "reply"]); + + for (const pk of thread.pubKeys) { + if (pk === this.#pubKey) { + continue; + } + eb.tag(["p", pk]); + } + } else { + eb.tag(["e", replyTo.id, "", "reply"]); + // dont tag self in replies + if (replyTo.pubkey !== this.#pubKey) { + eb.tag(["p", replyTo.pubkey]); + } + } + eb.processContent(); + fnExtra?.(eb); + return await this.#sign(eb); + } + + async react(evRef: RawEvent, content = "+") { + const eb = this.#eb(EventKind.Reaction); + eb.content(content); + eb.tag(["e", evRef.id]); + eb.tag(["p", evRef.pubkey]); + return await this.#sign(eb); + } + + async relayList(relays: Array | Record) { + if (!Array.isArray(relays)) { + relays = Object.entries(relays).map(([k, v]) => ({ + url: k, + settings: v, + })); + } + const eb = this.#eb(EventKind.Relays); + for (const rx of relays) { + const rTag = ["r", rx.url]; + if (rx.settings.read && !rx.settings.write) { + rTag.push("read"); + } + if (rx.settings.write && !rx.settings.read) { + rTag.push("write"); + } + eb.tag(rTag); + } + return await this.#sign(eb); + } + + async contactList(follows: Array, relays: Record) { + const eb = this.#eb(EventKind.ContactList); + eb.content(JSON.stringify(relays)); + + const temp = new Set(follows.filter(a => a.length === 64).map(a => a.toLowerCase())); + temp.forEach(a => eb.tag(["p", a])); + return await this.#sign(eb); + } + + /** + * Delete an event (NIP-09) + */ + async delete(id: u256) { + const eb = this.#eb(EventKind.Deletion); + eb.tag(["e", id]); + return await this.#sign(eb); + } + /** + * Repost a note (NIP-18) + */ + async repost(note: RawEvent) { + const eb = this.#eb(EventKind.Repost); + eb.tag(["e", note.id, ""]); + eb.tag(["p", note.pubkey]); + return await this.#sign(eb); + } + + async decryptDm(note: RawEvent) { + if (note.pubkey !== this.#pubKey && !note.tags.some(a => a[1] === this.#pubKey)) { + throw new Error("Can't decrypt, DM does not belong to this user"); + } + const otherPubKey = note.pubkey === this.#pubKey ? unwrap(note.tags.find(a => a[0] === "p")?.[1]) : note.pubkey; + return await this.nip4Decrypt(note.content, otherPubKey); + } + + async sendDm(content: string, to: HexKey) { + const eb = this.#eb(EventKind.DirectMessage); + eb.content(await this.nip4Encrypt(content, to)); + eb.tag(["p", to]); + return await this.#sign(eb); + } + + async generic(fnHook: EventBuilderHook) { + const eb = new EventBuilder(); + fnHook(eb); + return await this.#sign(eb); + } +} diff --git a/packages/app/src/System/index.ts b/packages/app/src/System/index.ts index 1e59ae9a..c5c254a1 100644 --- a/packages/app/src/System/index.ts +++ b/packages/app/src/System/index.ts @@ -2,6 +2,7 @@ import { AuthHandler, TaggedRawEvent, RelaySettings, Connection, RawReqFilter, R import { sanitizeRelayUrl, unixNowMs, unwrap } from "Util"; import { RequestBuilder } from "./RequestBuilder"; +import { EventBuilder } from "./EventBuilder"; import { FlatNoteStore, NoteStore, @@ -18,6 +19,7 @@ export { PubkeyReplaceableNoteStore, ParameterizedReplaceableNoteStore, Query, + EventBuilder, }; export interface SystemSnapshot { diff --git a/packages/app/src/nip6.ts b/packages/app/src/nip6.ts index 89639c4c..9f7f150c 100644 --- a/packages/app/src/nip6.ts +++ b/packages/app/src/nip6.ts @@ -23,11 +23,9 @@ export function hexToMnemonic(hex: string): string { } /** - * Convert mnemonic phrase into hex-encoded private key - * using the derivation path specified in NIP06 - * @param mnemonic the mnemonic-encoded entropy + * Derrive NIP-06 private key from master key */ -export function entropyToDerivedKey(entropy: Uint8Array): string { +export function entropyToPrivateKey(entropy: Uint8Array): string { const masterKey = HDKey.fromMasterSeed(entropy); const newKey = masterKey.derive(DerivationPath);