From f6a46e3523665b792e7b2c93b370b92a3845f781 Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 17 Aug 2023 19:54:14 +0100 Subject: [PATCH] feat: NIP-24 --- packages/app/src/Cache/GiftWrapCache.ts | 59 ++++++++ packages/app/src/Cache/index.ts | 3 + packages/app/src/Db/index.ts | 12 +- packages/app/src/Element/BadgeList.tsx | 4 +- packages/app/src/Element/Bookmarks.tsx | 6 +- packages/app/src/Element/DM.tsx | 26 ++-- packages/app/src/Element/DmWindow.css | 9 ++ packages/app/src/Element/DmWindow.tsx | 39 ++++-- packages/app/src/Element/LiveChat.tsx | 6 +- packages/app/src/Element/Note.tsx | 14 +- packages/app/src/Element/NoteCreator.tsx | 6 +- packages/app/src/Element/NoteFooter.tsx | 10 +- packages/app/src/Element/NoteReaction.tsx | 8 +- packages/app/src/Element/Poll.tsx | 4 +- packages/app/src/Element/Reactions.tsx | 8 +- packages/app/src/Element/Thread.tsx | 28 ++-- packages/app/src/Element/Timeline.tsx | 6 +- packages/app/src/Element/TrendingPosts.tsx | 4 +- packages/app/src/Feed/FollowsFeed.ts | 4 +- packages/app/src/Feed/LoginFeed.ts | 19 +-- packages/app/src/Feed/MuteList.ts | 8 +- packages/app/src/Feed/RelaysFeedFollows.tsx | 6 +- packages/app/src/Notifications.ts | 6 +- packages/app/src/Pages/MessagesPage.tsx | 61 ++++++--- packages/app/src/SnortUtils/index.ts | 24 ++-- packages/app/src/State/NoteCreator.ts | 6 +- packages/app/src/Wallet/NostrWalletConnect.ts | 7 +- packages/app/src/chat/index.ts | 42 +++++- packages/app/src/chat/nip24.ts | 129 ++++++++++++++++++ packages/app/src/chat/nip28.ts | 0 packages/app/src/chat/nip29.ts | 42 ++++-- packages/app/src/chat/nip4.ts | 52 +++++-- packages/system-react/README.md | 4 +- packages/system-react/example/example.tsx | 4 +- packages/system/src/connection.ts | 4 +- packages/system/src/event-ext.ts | 16 +-- packages/system/src/event-kind.ts | 2 + packages/system/src/event-publisher.ts | 114 ++++++++-------- packages/system/src/impl/nip4.ts | 24 ++-- packages/system/src/impl/nip44.ts | 30 ++-- packages/system/src/impl/nip46.ts | 10 +- packages/system/src/impl/nip7.ts | 11 +- packages/system/src/index.ts | 16 ++- packages/system/src/links.ts | 32 +++++ packages/system/src/nostr-system.ts | 4 +- packages/system/src/nostr.ts | 4 +- packages/system/src/note-collection.ts | 42 +++--- packages/system/src/profile-cache.ts | 6 +- packages/system/src/query.ts | 4 +- packages/system/src/signer.ts | 108 +++++++++++++++ packages/system/tests/note-collection.test.ts | 18 +-- 51 files changed, 792 insertions(+), 319 deletions(-) create mode 100644 packages/app/src/Cache/GiftWrapCache.ts create mode 100644 packages/app/src/chat/nip24.ts create mode 100644 packages/app/src/chat/nip28.ts create mode 100644 packages/system/src/signer.ts diff --git a/packages/app/src/Cache/GiftWrapCache.ts b/packages/app/src/Cache/GiftWrapCache.ts new file mode 100644 index 00000000..184ad3ad --- /dev/null +++ b/packages/app/src/Cache/GiftWrapCache.ts @@ -0,0 +1,59 @@ +import { FeedCache } from "@snort/shared"; +import { EventKind, EventPublisher, TaggedNostrEvent } from "@snort/system"; +import { UnwrappedGift, db } from "Db"; +import { findTag, unwrap } from "SnortUtils"; + +export class GiftWrapCache extends FeedCache { + constructor() { + super("GiftWrapCache", db.gifts); + } + + key(of: UnwrappedGift): string { + return of.id; + } + + override async preload(): Promise { + await super.preload(); + await this.buffer([...this.onTable]); + } + + newest(): number { + let ret = 0; + this.cache.forEach(v => (ret = v.created_at > ret ? v.created_at : ret)); + return ret; + } + + takeSnapshot(): Array { + return [...this.cache.values()]; + } + + async onEvent(evs: Array, pub: EventPublisher) { + const unwrapped = ( + await Promise.all( + evs.map(async v => { + try { + return { + id: v.id, + to: findTag(v, "p"), + created_at: v.created_at, + inner: await pub.unwrapGift(v), + } as UnwrappedGift; + } catch (e) { + console.debug(e, v); + } + }) + ) + ) + .filter(a => a !== undefined) + .map(unwrap); + + // HACK: unseal to get p tags + for (const u of unwrapped) { + if (u.inner.kind === EventKind.SealedRumor) { + const unsealed = await pub.unsealRumor(u.inner); + u.tags = unsealed.tags; + } + } + await this.bulkSet(unwrapped); + } +} diff --git a/packages/app/src/Cache/index.ts b/packages/app/src/Cache/index.ts index 33917aa5..905ba539 100644 --- a/packages/app/src/Cache/index.ts +++ b/packages/app/src/Cache/index.ts @@ -2,6 +2,7 @@ import { UserProfileCache, UserRelaysCache, RelayMetricCache } from "@snort/syst import { EventInteractionCache } from "./EventInteractionCache"; import { ChatCache } from "./ChatCache"; import { Payments } from "./PaymentsCache"; +import { GiftWrapCache } from "./GiftWrapCache"; export const UserCache = new UserProfileCache(); export const UserRelays = new UserRelaysCache(); @@ -9,6 +10,7 @@ export const RelayMetrics = new RelayMetricCache(); export const Chats = new ChatCache(); export const PaymentsCache = new Payments(); export const InteractionCache = new EventInteractionCache(); +export const GiftsCache = new GiftWrapCache(); export async function preload(follows?: Array) { const preloads = [ @@ -17,6 +19,7 @@ export async function preload(follows?: Array) { InteractionCache.preload(), UserRelays.preload(follows), RelayMetrics.preload(), + GiftsCache.preload(), ]; await Promise.all(preloads); } diff --git a/packages/app/src/Db/index.ts b/packages/app/src/Db/index.ts index c589fb54..8d29e7f9 100644 --- a/packages/app/src/Db/index.ts +++ b/packages/app/src/Db/index.ts @@ -2,7 +2,7 @@ import Dexie, { Table } from "dexie"; import { HexKey, NostrEvent, u256 } from "@snort/system"; export const NAME = "snortDB"; -export const VERSION = 11; +export const VERSION = 12; export interface SubCache { id: string; @@ -27,10 +27,19 @@ export interface Payment { macaroon: string; } +export interface UnwrappedGift { + id: string; + to: string; + created_at: number; + inner: NostrEvent; + tags?: Array>; // some tags extracted +} + const STORES = { chats: "++id", eventInteraction: "++id", payments: "++url", + gifts: "++id", }; export class SnortDB extends Dexie { @@ -38,6 +47,7 @@ export class SnortDB extends Dexie { chats!: Table; eventInteraction!: Table; payments!: Table; + gifts!: Table; constructor() { super(NAME); diff --git a/packages/app/src/Element/BadgeList.tsx b/packages/app/src/Element/BadgeList.tsx index 36088902..06cbb319 100644 --- a/packages/app/src/Element/BadgeList.tsx +++ b/packages/app/src/Element/BadgeList.tsx @@ -3,7 +3,7 @@ import "./BadgeList.css"; import { useState } from "react"; import { FormattedMessage } from "react-intl"; -import { TaggedRawEvent } from "@snort/system"; +import { TaggedNostrEvent } from "@snort/system"; import { ProxyImg } from "Element/ProxyImg"; import Icon from "Icons/Icon"; @@ -11,7 +11,7 @@ import Modal from "Element/Modal"; import Username from "Element/Username"; import { findTag } from "SnortUtils"; -export default function BadgeList({ badges }: { badges: TaggedRawEvent[] }) { +export default function BadgeList({ badges }: { badges: TaggedNostrEvent[] }) { const [showModal, setShowModal] = useState(false); const badgeMetadata = badges.map(b => { const thumb = findTag(b, "thumb"); diff --git a/packages/app/src/Element/Bookmarks.tsx b/packages/app/src/Element/Bookmarks.tsx index 843ea3bd..f41511ca 100644 --- a/packages/app/src/Element/Bookmarks.tsx +++ b/packages/app/src/Element/Bookmarks.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, ChangeEvent } from "react"; import { FormattedMessage } from "react-intl"; -import { HexKey, TaggedRawEvent } from "@snort/system"; +import { HexKey, TaggedNostrEvent } from "@snort/system"; import Note from "Element/Note"; import useLogin from "Hooks/useLogin"; @@ -10,8 +10,8 @@ import messages from "./messages"; interface BookmarksProps { pubkey: HexKey; - bookmarks: readonly TaggedRawEvent[]; - related: readonly TaggedRawEvent[]; + bookmarks: readonly TaggedNostrEvent[]; + related: readonly TaggedNostrEvent[]; } const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => { diff --git a/packages/app/src/Element/DM.tsx b/packages/app/src/Element/DM.tsx index f5745817..3e8f8ce0 100644 --- a/packages/app/src/Element/DM.tsx +++ b/packages/app/src/Element/DM.tsx @@ -2,56 +2,54 @@ import "./DM.css"; import { useEffect, useState } from "react"; import { useIntl } from "react-intl"; import { useInView } from "react-intersection-observer"; -import { EventKind, TaggedRawEvent } from "@snort/system"; import useEventPublisher from "Feed/EventPublisher"; import NoteTime from "Element/NoteTime"; import Text from "Element/Text"; import useLogin from "Hooks/useLogin"; -import { Chat, ChatType, chatTo, setLastReadIn } from "chat"; +import { Chat, ChatMessage, ChatType, setLastReadIn } from "chat"; import messages from "./messages"; import ProfileImage from "./ProfileImage"; export interface DMProps { chat: Chat; - data: TaggedRawEvent; + data: ChatMessage; } export default function DM(props: DMProps) { const pubKey = useLogin().publicKey; const publisher = useEventPublisher(); - const ev = props.data; - const needsDecryption = ev.kind === EventKind.DirectMessage; - const [content, setContent] = useState(needsDecryption ? "Loading..." : ev.content); + const msg = props.data; + const [content, setContent] = useState(msg.needsDecryption ? "Loading..." : msg.content); const [decrypted, setDecrypted] = useState(false); const { ref, inView } = useInView(); const { formatMessage } = useIntl(); - const isMe = ev.pubkey === pubKey; - const otherPubkey = isMe ? pubKey : chatTo(ev); + const isMe = msg.from === pubKey; + const otherPubkey = isMe ? pubKey : msg.from; async function decrypt() { if (publisher) { - const decrypted = await publisher.decryptDm(ev); + const decrypted = await msg.decrypt(publisher); setContent(decrypted || ""); if (!isMe) { - setLastReadIn(ev.pubkey); + setLastReadIn(msg.id); } } } function sender() { if (props.chat.type !== ChatType.DirectMessage && !isMe) { - return ; + return ; } } useEffect(() => { - if (!decrypted && inView && needsDecryption) { + if (!decrypted && inView && msg.needsDecryption) { setDecrypted(true); decrypt().catch(console.error); } - }, [inView, ev]); + }, [inView, msg]); return (
@@ -60,7 +58,7 @@ export default function DM(props: DMProps) {
- +
); diff --git a/packages/app/src/Element/DmWindow.css b/packages/app/src/Element/DmWindow.css index 8a75ff5b..9d31a306 100644 --- a/packages/app/src/Element/DmWindow.css +++ b/packages/app/src/Element/DmWindow.css @@ -19,3 +19,12 @@ gap: 10px; padding: 5px 10px; } + +.pfp-overlap .pfp:not(:last-of-type) { + margin-right: -20px; +} + +.pfp-overlap .avatar { + width: 32px; + height: 32px; +} diff --git a/packages/app/src/Element/DmWindow.tsx b/packages/app/src/Element/DmWindow.tsx index 84e26413..e458ab66 100644 --- a/packages/app/src/Element/DmWindow.tsx +++ b/packages/app/src/Element/DmWindow.tsx @@ -1,31 +1,46 @@ import "./DmWindow.css"; import { useMemo } from "react"; -import { TaggedRawEvent } from "@snort/system"; import ProfileImage from "Element/ProfileImage"; import DM from "Element/DM"; import NoteToSelf from "Element/NoteToSelf"; import useLogin from "Hooks/useLogin"; import WriteMessage from "Element/WriteMessage"; -import { Chat, ChatType, useChatSystem } from "chat"; +import { Chat, ChatParticipant, ChatType, useChatSystem } from "chat"; import { Nip4ChatSystem } from "chat/nip4"; +import { FormattedMessage } from "react-intl"; export default function DmWindow({ id }: { id: string }) { const pubKey = useLogin().publicKey; const dms = useChatSystem(); const chat = dms.find(a => a.id === id) ?? Nip4ChatSystem.createChatObj(id, []); + function participant(p: ChatParticipant) { + if (p.id === pubKey) { + return ; + } + if (p.type === "pubkey") { + return ; + } + if (p?.profile) { + return ; + } + return ; + } + function sender() { - if (id === pubKey) { - return ; + if (chat.participants.length === 1) { + return participant(chat.participants[0]); + } else { + return ( +
+ {chat.participants.map(v => ( + + ))} + {chat.title ?? } +
+ ); } - if (chat?.type === ChatType.DirectMessage) { - return ; - } - if (chat?.profile) { - return ; - } - return ; } return ( @@ -55,7 +70,7 @@ function DmChatSelected({ chat }: { chat: Chat }) { return ( <> {sortedDms.map(a => ( - + ))} ); diff --git a/packages/app/src/Element/LiveChat.tsx b/packages/app/src/Element/LiveChat.tsx index 60fb83e9..0a422c38 100644 --- a/packages/app/src/Element/LiveChat.tsx +++ b/packages/app/src/Element/LiveChat.tsx @@ -1,5 +1,5 @@ import "./LiveChat.css"; -import { EventKind, NostrLink, TaggedRawEvent } from "@snort/system"; +import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system"; import { useUserProfile } from "@snort/system-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -15,7 +15,7 @@ import Text from "Element/Text"; import { System } from "index"; import { profileLink } from "SnortUtils"; -export function LiveChat({ ev, link }: { ev: TaggedRawEvent; link: NostrLink }) { +export function LiveChat({ ev, link }: { ev: TaggedNostrEvent; link: NostrLink }) { const [chat, setChat] = useState(""); const messages = useLiveChatFeed(link); const pub = useEventPublisher(); @@ -74,7 +74,7 @@ export function LiveChat({ ev, link }: { ev: TaggedRawEvent; link: NostrLink }) ); } -function ChatMessage({ ev }: { ev: TaggedRawEvent }) { +function ChatMessage({ ev }: { ev: TaggedNostrEvent }) { const profile = useUserProfile(System, ev.pubkey); const navigate = useNavigate(); diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Note.tsx index 24af94cf..4d61db22 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, Lists, EventExt, parseZap } from "@snort/system"; +import { TaggedNostrEvent, HexKey, EventKind, NostrPrefix, Lists, EventExt, parseZap } from "@snort/system"; import { System } from "index"; import useEventPublisher from "Feed/EventPublisher"; @@ -36,12 +36,12 @@ import { LiveEvent } from "Element/LiveEvent"; import messages from "./messages"; export interface NoteProps { - data: TaggedRawEvent; + data: TaggedNostrEvent; className?: string; - related: readonly TaggedRawEvent[]; + related: readonly TaggedNostrEvent[]; highlight?: boolean; ignoreModeration?: boolean; - onClick?: (e: TaggedRawEvent) => void; + onClick?: (e: TaggedNostrEvent) => void; depth?: number; options?: { showHeader?: boolean; @@ -113,8 +113,8 @@ export default function Note(props: NoteProps) { return { ...acc, [kind]: [...rs, reaction] }; }, { - [Reaction.Positive]: [] as TaggedRawEvent[], - [Reaction.Negative]: [] as TaggedRawEvent[], + [Reaction.Positive]: [] as TaggedNostrEvent[], + [Reaction.Negative]: [] as TaggedNostrEvent[], } ); return { @@ -219,7 +219,7 @@ export default function Note(props: NoteProps) { function goToEvent( e: React.MouseEvent, - eTarget: TaggedRawEvent, + eTarget: TaggedNostrEvent, isTargetAllowed: boolean = e.target === e.currentTarget ) { if (!isTargetAllowed || opt?.canClick === false) { diff --git a/packages/app/src/Element/NoteCreator.tsx b/packages/app/src/Element/NoteCreator.tsx index ef793f83..75c00fe3 100644 --- a/packages/app/src/Element/NoteCreator.tsx +++ b/packages/app/src/Element/NoteCreator.tsx @@ -1,7 +1,7 @@ import "./NoteCreator.css"; import { FormattedMessage, useIntl } from "react-intl"; import { useDispatch, useSelector } from "react-redux"; -import { encodeTLV, EventKind, NostrPrefix, TaggedRawEvent, EventBuilder } from "@snort/system"; +import { encodeTLV, EventKind, NostrPrefix, TaggedNostrEvent, EventBuilder } from "@snort/system"; import { LNURL } from "@snort/shared"; import Icon from "Icons/Icon"; @@ -38,7 +38,7 @@ import useLogin from "Hooks/useLogin"; import { System } from "index"; interface NotePreviewProps { - note: TaggedRawEvent; + note: TaggedNostrEvent; } function NotePreview({ note }: NotePreviewProps) { @@ -194,7 +194,7 @@ export function NoteCreator() { if (preview) { return ( void; } diff --git a/packages/app/src/Element/NoteReaction.tsx b/packages/app/src/Element/NoteReaction.tsx index 00301dac..aa7cf1c2 100644 --- a/packages/app/src/Element/NoteReaction.tsx +++ b/packages/app/src/Element/NoteReaction.tsx @@ -1,7 +1,7 @@ import "./NoteReaction.css"; import { Link } from "react-router-dom"; import { useMemo } from "react"; -import { EventKind, NostrEvent, TaggedRawEvent, NostrPrefix, EventExt } from "@snort/system"; +import { EventKind, NostrEvent, TaggedNostrEvent, NostrPrefix, EventExt } from "@snort/system"; import Note from "Element/Note"; import ProfileImage from "Element/ProfileImage"; @@ -10,8 +10,8 @@ import NoteTime from "Element/NoteTime"; import useModeration from "Hooks/useModeration"; export interface NoteReactionProps { - data: TaggedRawEvent; - root?: TaggedRawEvent; + data: TaggedNostrEvent; + root?: TaggedNostrEvent; } export default function NoteReaction(props: NoteReactionProps) { const { data: ev } = props; @@ -43,7 +43,7 @@ export default function NoteReaction(props: NoteReactionProps) { if (ev?.kind === EventKind.Repost && ev.content.length > 0 && ev.content !== "#[0]") { try { const r: NostrEvent = JSON.parse(ev.content); - return r as TaggedRawEvent; + return r as TaggedNostrEvent; } catch (e) { console.error("Could not load reposted content", e); } diff --git a/packages/app/src/Element/Poll.tsx b/packages/app/src/Element/Poll.tsx index 434f22f1..cd9464dc 100644 --- a/packages/app/src/Element/Poll.tsx +++ b/packages/app/src/Element/Poll.tsx @@ -1,4 +1,4 @@ -import { TaggedRawEvent, ParsedZap } from "@snort/system"; +import { TaggedNostrEvent, ParsedZap } from "@snort/system"; import { LNURL } from "@snort/shared"; import { useState } from "react"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; @@ -15,7 +15,7 @@ import useLogin from "Hooks/useLogin"; import { System } from "index"; interface PollProps { - ev: TaggedRawEvent; + ev: TaggedNostrEvent; zaps: Array; } diff --git a/packages/app/src/Element/Reactions.tsx b/packages/app/src/Element/Reactions.tsx index 991663f4..cd755b26 100644 --- a/packages/app/src/Element/Reactions.tsx +++ b/packages/app/src/Element/Reactions.tsx @@ -2,7 +2,7 @@ import "./Reactions.css"; import { useState, useMemo, useEffect } from "react"; import { useIntl, FormattedMessage } from "react-intl"; -import { TaggedRawEvent, ParsedZap } from "@snort/system"; +import { TaggedNostrEvent, ParsedZap } from "@snort/system"; import { formatShort } from "Number"; import Icon from "Icons/Icon"; @@ -16,9 +16,9 @@ import messages from "./messages"; interface ReactionsProps { show: boolean; setShow(b: boolean): void; - positive: TaggedRawEvent[]; - negative: TaggedRawEvent[]; - reposts: TaggedRawEvent[]; + positive: TaggedNostrEvent[]; + negative: TaggedNostrEvent[]; + reposts: TaggedNostrEvent[]; zaps: ParsedZap[]; } diff --git a/packages/app/src/Element/Thread.tsx b/packages/app/src/Element/Thread.tsx index 21a433f2..77e1f265 100644 --- a/packages/app/src/Element/Thread.tsx +++ b/packages/app/src/Element/Thread.tsx @@ -3,7 +3,7 @@ import { useMemo, useState, ReactNode } from "react"; import { useIntl } from "react-intl"; import { useNavigate, useLocation, Link, useParams } from "react-router-dom"; import { - TaggedRawEvent, + TaggedNostrEvent, u256, EventKind, NostrPrefix, @@ -37,14 +37,14 @@ const Divider = ({ variant = "regular" }: DividerProps) => { interface SubthreadProps { isLastSubthread?: boolean; active: u256; - notes: readonly TaggedRawEvent[]; - related: readonly TaggedRawEvent[]; - chains: Map>; - onNavigate: (e: TaggedRawEvent) => void; + notes: readonly TaggedNostrEvent[]; + related: readonly TaggedNostrEvent[]; + chains: Map>; + onNavigate: (e: TaggedNostrEvent) => void; } const Subthread = ({ active, notes, related, chains, onNavigate }: SubthreadProps) => { - const renderSubthread = (a: TaggedRawEvent, idx: number) => { + const renderSubthread = (a: TaggedNostrEvent, idx: number) => { const isLastSubthread = idx === notes.length - 1; const replies = getReplies(a.id, chains); return ( @@ -79,7 +79,7 @@ const Subthread = ({ active, notes, related, chains, onNavigate }: SubthreadProp }; interface ThreadNoteProps extends Omit { - note: TaggedRawEvent; + note: TaggedNostrEvent; isLast: boolean; } @@ -136,7 +136,7 @@ const TierTwo = ({ active, isLastSubthread, notes, related, chains, onNavigate } isLast={rest.length === 0} /> - {rest.map((r: TaggedRawEvent, idx: number) => { + {rest.map((r: TaggedNostrEvent, idx: number) => { const lastReply = idx === rest.length - 1; return ( )} - {rest.map((r: TaggedRawEvent, idx: number) => { + {rest.map((r: TaggedNostrEvent, idx: number) => { const lastReply = idx === rest.length - 1; const lastNote = isLastSubthread && lastReply; return ( @@ -226,13 +226,13 @@ export default function Thread() { const isSingleNote = thread.data?.filter(a => a.kind === EventKind.TextNote).length === 1; const { formatMessage } = useIntl(); - function navigateThread(e: TaggedRawEvent) { + function navigateThread(e: TaggedNostrEvent) { setCurrentId(e.id); //const link = encodeTLV(e.id, NostrPrefix.Event, e.relays); } const chains = useMemo(() => { - const chains = new Map>(); + const chains = new Map>(); if (thread.data) { thread.data ?.filter(a => a.kind === EventKind.TextNote) @@ -265,7 +265,7 @@ export default function Thread() { ne => ne.id === currentId || (link.type === NostrPrefix.Address && findTag(ne, "d") === currentId && ne.pubkey === link.author) - ) ?? (location.state && "sig" in location.state ? (location.state as TaggedRawEvent) : undefined); + ) ?? (location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined); if (currentNote) { const currentThread = EventExt.extractThread(currentNote); const isRoot = (ne?: ThreadInfo) => ne === undefined; @@ -318,7 +318,7 @@ export default function Thread() { const brokenChains = Array.from(chains?.keys()).filter(a => !thread.data?.some(b => b.id === a)); - function renderRoot(note: TaggedRawEvent) { + function renderRoot(note: TaggedNostrEvent) { const className = `thread-root${isSingleNote ? " thread-root-single" : ""}`; if (note) { return ( @@ -396,7 +396,7 @@ export default function Thread() { ); } -function getReplies(from: u256, chains?: Map>): Array { +function getReplies(from: u256, chains?: Map>): Array { if (!from || !chains) { return []; } diff --git a/packages/app/src/Element/Timeline.tsx b/packages/app/src/Element/Timeline.tsx index 0da862db..64d260f9 100644 --- a/packages/app/src/Element/Timeline.tsx +++ b/packages/app/src/Element/Timeline.tsx @@ -2,7 +2,7 @@ import "./Timeline.css"; import { FormattedMessage } from "react-intl"; import { useCallback, useMemo } from "react"; import { useInView } from "react-intersection-observer"; -import { TaggedRawEvent, EventKind, u256, parseZap } from "@snort/system"; +import { TaggedNostrEvent, EventKind, u256, parseZap } from "@snort/system"; import Icon from "Icons/Icon"; import { dedupeByPubkey, findTag, tagFilterOfTextRepost } from "SnortUtils"; @@ -43,7 +43,7 @@ const Timeline = (props: TimelineProps) => { const { ref, inView } = useInView(); const filterPosts = useCallback( - (nts: readonly TaggedRawEvent[]) => { + (nts: readonly TaggedNostrEvent[]) => { const a = [...nts]; props.noSort || a.sort((a, b) => b.created_at - a.created_at); return a @@ -76,7 +76,7 @@ const Timeline = (props: TimelineProps) => { return dedupeByPubkey(latestFeed).map(e => e.pubkey); }, [latestFeed]); - function eventElement(e: TaggedRawEvent) { + function eventElement(e: TaggedNostrEvent) { switch (e.kind) { case EventKind.SetMetadata: { return } pubkey={e.pubkey} className="card" />; diff --git a/packages/app/src/Element/TrendingPosts.tsx b/packages/app/src/Element/TrendingPosts.tsx index 863174e6..8978b2b5 100644 --- a/packages/app/src/Element/TrendingPosts.tsx +++ b/packages/app/src/Element/TrendingPosts.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { NostrEvent, TaggedRawEvent } from "@snort/system"; +import { NostrEvent, TaggedNostrEvent } from "@snort/system"; import { FormattedMessage } from "react-intl"; import PageSpinner from "Element/PageSpinner"; @@ -27,7 +27,7 @@ export default function TrendingNotes() { {posts.map(e => ( - + ))} ); diff --git a/packages/app/src/Feed/FollowsFeed.ts b/packages/app/src/Feed/FollowsFeed.ts index f19ee011..af5a2fca 100644 --- a/packages/app/src/Feed/FollowsFeed.ts +++ b/packages/app/src/Feed/FollowsFeed.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { HexKey, TaggedRawEvent, EventKind, NoteCollection, RequestBuilder } from "@snort/system"; +import { HexKey, TaggedNostrEvent, EventKind, NoteCollection, RequestBuilder } from "@snort/system"; import { useRequestBuilder } from "@snort/system-react"; import useLogin from "Hooks/useLogin"; @@ -26,7 +26,7 @@ export default function useFollowsFeed(pubkey?: HexKey) { }, [contactFeed, follows, pubkey]); } -export function getFollowing(notes: readonly TaggedRawEvent[], pubkey?: HexKey) { +export function getFollowing(notes: readonly TaggedNostrEvent[], pubkey?: HexKey) { const contactLists = notes.filter(a => a.kind === EventKind.ContactList && a.pubkey === pubkey); const pTags = contactLists?.map(a => a.tags.filter(b => b[0] === "p").map(c => c[1])); return [...new Set(pTags?.flat())]; diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index abd13291..d0fd0e1d 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo } from "react"; -import { TaggedRawEvent, Lists, EventKind, FlatNoteStore, RequestBuilder } from "@snort/system"; +import { TaggedNostrEvent, Lists, EventKind, FlatNoteStore, RequestBuilder } from "@snort/system"; import { useRequestBuilder } from "@snort/system-react"; import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "SnortUtils"; @@ -12,7 +12,7 @@ import { addSubscription, setBlocked, setBookmarked, setFollows, setMuted, setPi import { SnortPubKey } from "Const"; import { SubscriptionEvent } from "Subscription"; import useRelaysFeedFollows from "./RelaysFeedFollows"; -import { UserRelays } from "Cache"; +import { GiftsCache, UserRelays } from "Cache"; import { System } from "index"; import { Nip29Chats, Nip4Chats } from "chat"; @@ -39,9 +39,9 @@ export default function useLoginFeed() { .authors([bech32ToHex(SnortPubKey)]) .tag("p", [pubKey]) .limit(1); + b.withFilter().kinds([EventKind.GiftWrap]).tag("p", [pubKey]).since(GiftsCache.newest()); b.add(Nip4Chats.subscription(pubKey)); - b.add(Nip29Chats.subscription("n29.nostr.com/")); return b; }, [pubKey]); @@ -83,6 +83,9 @@ export default function useLoginFeed() { ); Nip29Chats.onEvent(nip29Messages); + const giftWraps = loginFeed.data.filter(a => a.kind === EventKind.GiftWrap); + GiftsCache.onEvent(giftWraps, publisher); + const subs = loginFeed.data.filter( a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey) ); @@ -116,7 +119,7 @@ export default function useLoginFeed() { } }, [loginFeed, readNotifications]); - function handleMutedFeed(mutedFeed: TaggedRawEvent[]) { + function handleMutedFeed(mutedFeed: TaggedNostrEvent[]) { const muted = getMutedKeys(mutedFeed); setMuted(login, muted.keys, muted.createdAt * 1000); @@ -136,21 +139,21 @@ export default function useLoginFeed() { } } - function handlePinnedFeed(pinnedFeed: TaggedRawEvent[]) { + function handlePinnedFeed(pinnedFeed: TaggedNostrEvent[]) { const newest = getNewestEventTagsByKey(pinnedFeed, "e"); if (newest) { setPinned(login, newest.keys, newest.createdAt * 1000); } } - function handleTagFeed(tagFeed: TaggedRawEvent[]) { + function handleTagFeed(tagFeed: TaggedNostrEvent[]) { const newest = getNewestEventTagsByKey(tagFeed, "t"); if (newest) { setTags(login, newest.keys, newest.createdAt * 1000); } } - function handleBookmarkFeed(bookmarkFeed: TaggedRawEvent[]) { + function handleBookmarkFeed(bookmarkFeed: TaggedNostrEvent[]) { const newest = getNewestEventTagsByKey(bookmarkFeed, "e"); if (newest) { setBookmarked(login, newest.keys, newest.createdAt * 1000); @@ -161,7 +164,7 @@ export default function useLoginFeed() { useEffect(() => { if (listsFeed.data) { - const getList = (evs: readonly TaggedRawEvent[], list: Lists) => + const getList = (evs: readonly TaggedNostrEvent[], list: Lists) => evs.filter(a => unwrap(a.tags.find(b => b[0] === "d"))[1] === list); const mutedFeed = getList(listsFeed.data, Lists.Muted); diff --git a/packages/app/src/Feed/MuteList.ts b/packages/app/src/Feed/MuteList.ts index 95c6f002..719b79d0 100644 --- a/packages/app/src/Feed/MuteList.ts +++ b/packages/app/src/Feed/MuteList.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { HexKey, TaggedRawEvent, Lists, EventKind, NoteCollection, RequestBuilder } from "@snort/system"; +import { HexKey, TaggedNostrEvent, Lists, EventKind, NoteCollection, RequestBuilder } from "@snort/system"; import { useRequestBuilder } from "@snort/system-react"; import { getNewest } from "SnortUtils"; @@ -29,10 +29,10 @@ export default function useMutedFeed(pubkey?: HexKey) { return isMe ? muted.item : mutedList; } -export function getMutedKeys(rawNotes: TaggedRawEvent[]): { +export function getMutedKeys(rawNotes: TaggedNostrEvent[]): { createdAt: number; keys: HexKey[]; - raw?: TaggedRawEvent; + raw?: TaggedNostrEvent; } { const newest = getNewest(rawNotes); if (newest) { @@ -47,7 +47,7 @@ export function getMutedKeys(rawNotes: TaggedRawEvent[]): { return { createdAt: 0, keys: [] }; } -export function getMuted(feed: readonly TaggedRawEvent[], pubkey: HexKey): HexKey[] { +export function getMuted(feed: readonly TaggedNostrEvent[], pubkey: HexKey): HexKey[] { const lists = feed.filter(a => a.kind === EventKind.PubkeyLists && a.pubkey === pubkey); return getMutedKeys(lists).keys; } diff --git a/packages/app/src/Feed/RelaysFeedFollows.tsx b/packages/app/src/Feed/RelaysFeedFollows.tsx index 5820ef87..fcb776a0 100644 --- a/packages/app/src/Feed/RelaysFeedFollows.tsx +++ b/packages/app/src/Feed/RelaysFeedFollows.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { HexKey, FullRelaySettings, - TaggedRawEvent, + TaggedNostrEvent, RelaySettings, EventKind, NoteCollection, @@ -30,7 +30,7 @@ export default function useRelaysFeedFollows(pubkeys: HexKey[]): Array): Array { + function mapFromRelays(notes: Array): Array { return notes.map(ev => { return { pubkey: ev.pubkey, @@ -51,7 +51,7 @@ export default function useRelaysFeedFollows(pubkeys: HexKey[]): Array): Array { + function mapFromContactList(notes: Array): Array { return notes.map(ev => { if (ev.content !== "" && ev.content !== "{}" && ev.content.startsWith("{") && ev.content.endsWith("}")) { try { diff --git a/packages/app/src/Notifications.ts b/packages/app/src/Notifications.ts index eb724226..764dad46 100644 --- a/packages/app/src/Notifications.ts +++ b/packages/app/src/Notifications.ts @@ -1,6 +1,6 @@ import Nostrich from "public/logo_256.png"; -import { TaggedRawEvent, EventKind, MetadataCache } from "@snort/system"; +import { TaggedNostrEvent, EventKind, MetadataCache } from "@snort/system"; import { getDisplayName } from "Element/ProfileImage"; import { MentionRegex } from "Const"; import { tagFilterOfTextRepost, unwrap } from "SnortUtils"; @@ -14,7 +14,7 @@ export interface NotificationRequest { timestamp: number; } -export async function makeNotification(ev: TaggedRawEvent): Promise { +export async function makeNotification(ev: TaggedNostrEvent): Promise { switch (ev.kind) { case EventKind.TextNote: { if (ev.tags.some(tagFilterOfTextRepost(ev))) { @@ -40,7 +40,7 @@ export async function makeNotification(ev: TaggedRawEvent): Promise { diff --git a/packages/app/src/Pages/MessagesPage.tsx b/packages/app/src/Pages/MessagesPage.tsx index 3cbe51af..4fb98fdf 100644 --- a/packages/app/src/Pages/MessagesPage.tsx +++ b/packages/app/src/Pages/MessagesPage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useNavigate, useParams } from "react-router-dom"; -import { NostrPrefix } from "@snort/system"; +import { NostrPrefix, TLVEntryType, decodeTLV } from "@snort/system"; import { useUserProfile } from "@snort/system-react"; import UnreadCount from "Element/UnreadCount"; @@ -44,15 +44,7 @@ export default function MessagesPage() { function openChat(e: React.MouseEvent, type: ChatType, id: string) { e.stopPropagation(); e.preventDefault(); - if (type === ChatType.DirectMessage) { - navigate(`/messages/${hexToBech32(NostrPrefix.PublicKey, id)}`, { - replace: true, - }); - } else { - navigate(`/messages/${encodeURIComponent(id)}`, { - replace: true, - }); - } + navigate(`/messages/${encodeURIComponent(id)}`); } function noteToSelf(chat: Chat) { @@ -63,16 +55,34 @@ export default function MessagesPage() { ); } - function person(chat: Chat) { + function conversationIdent(chat: Chat) { + if (chat.participants.length === 1) { + const p = chat.participants[0]; + + if (p.type === "pubkey") { + return ; + } else { + return ; + } + } else { + return ( +
+ {chat.participants.map(v => ( + + ))} +
{chat.title}
+
+ ); + } + } + + function conversation(chat: Chat) { if (!login.publicKey) return null; - if (chat.id === login.publicKey) return noteToSelf(chat); + const participants = chat.participants.map(a => a.id); + if (participants.length === 1 && participants[0] === login.publicKey) return noteToSelf(chat); return (
openChat(e, chat.type, chat.id)}> - {chat.type === ChatType.DirectMessage ? ( - - ) : ( - - )} + {conversationIdent(chat)}
@@ -97,22 +107,31 @@ export default function MessagesPage() {
{chats .sort((a, b) => { - return a.id === login.publicKey ? -1 : b.id === login.publicKey ? 1 : b.lastMessage - a.lastMessage; + const aSelf = a.participants.length === 1 && a.participants[0].id === login.publicKey; + const bSelf = b.participants.length === 1 && b.participants[0].id === login.publicKey; + if (aSelf || bSelf) { + return aSelf ? -1 : 1; + } + return b.lastMessage > a.lastMessage ? 1 : -1; }) - .map(person)} + .map(conversation)}
)} {chat && } {pageWidth >= ThreeCol && chat && (
- +
)} ); } -function ProfileDmActions({ pubkey }: { pubkey: string }) { +function ProfileDmActions({ id }: { id: string }) { + const authors = decodeTLV(id) + .filter(a => a.type === TLVEntryType.Author) + .map(a => a.value as string); + const pubkey = authors[0]; const profile = useUserProfile(System, pubkey); const { block, unblock, isBlocked } = useModeration(); diff --git a/packages/app/src/SnortUtils/index.ts b/packages/app/src/SnortUtils/index.ts index 70be2465..d7ca3781 100644 --- a/packages/app/src/SnortUtils/index.ts +++ b/packages/app/src/SnortUtils/index.ts @@ -6,7 +6,7 @@ import { bytesToHex } from "@noble/hashes/utils"; import { bech32, base32hex } from "@scure/base"; import { HexKey, - TaggedRawEvent, + TaggedNostrEvent, u256, EventKind, encodeTLV, @@ -149,11 +149,11 @@ export function normalizeReaction(content: string) { /** * Get reactions to a specific event (#e + kind filter) */ -export function getReactions(notes: readonly TaggedRawEvent[] | undefined, id: u256, kind?: EventKind) { +export function getReactions(notes: readonly TaggedNostrEvent[] | undefined, id: u256, kind?: EventKind) { return notes?.filter(a => a.kind === (kind ?? a.kind) && a.tags.some(a => a[0] === "e" && a[1] === id)) || []; } -export function getAllReactions(notes: readonly TaggedRawEvent[] | undefined, ids: Array, kind?: EventKind) { +export function getAllReactions(notes: readonly TaggedNostrEvent[] | undefined, ids: Array, kind?: EventKind) { return notes?.filter(a => a.kind === (kind ?? a.kind) && a.tags.some(a => a[0] === "e" && ids.includes(a[1]))) || []; } @@ -189,9 +189,9 @@ export function debounce(timeout: number, fn: () => void) { return () => clearTimeout(t); } -export function dedupeByPubkey(events: TaggedRawEvent[]) { +export function dedupeByPubkey(events: TaggedNostrEvent[]) { const deduped = events.reduce( - ({ list, seen }: { list: TaggedRawEvent[]; seen: Set }, ev) => { + ({ list, seen }: { list: TaggedNostrEvent[]; seen: Set }, ev) => { if (seen.has(ev.pubkey)) { return { list, seen }; } @@ -203,7 +203,7 @@ export function dedupeByPubkey(events: TaggedRawEvent[]) { }, { list: [], seen: new Set([]) } ); - return deduped.list as TaggedRawEvent[]; + return deduped.list as TaggedNostrEvent[]; } export function dedupeById(events: Array) { @@ -228,8 +228,8 @@ export function dedupeById(events: Array) { * @param events List of all notes to filter from * @returns */ -export function getLatestByPubkey(events: TaggedRawEvent[]): Map { - const deduped = events.reduce((results: Map, ev) => { +export function getLatestByPubkey(events: TaggedNostrEvent[]): Map { + const deduped = events.reduce((results: Map, ev) => { if (!results.has(ev.pubkey)) { const latest = getNewest(events.filter(a => a.pubkey === ev.pubkey)); if (latest) { @@ -237,7 +237,7 @@ export function getLatestByPubkey(events: TaggedRawEvent[]): Map()); + }, new Map()); return deduped; } @@ -274,7 +274,7 @@ export function randomSample(coll: T[], size: number) { return random.sort(() => (Math.random() >= 0.5 ? 1 : -1)).slice(0, size); } -export function getNewest(rawNotes: readonly TaggedRawEvent[]) { +export function getNewest(rawNotes: readonly TaggedNostrEvent[]) { const notes = [...rawNotes]; notes.sort((a, b) => b.created_at - a.created_at); if (notes.length > 0) { @@ -290,7 +290,7 @@ export function getNewestProfile(rawNotes: MetadataCache[]) { } } -export function getNewestEventTagsByKey(evs: TaggedRawEvent[], tag: string) { +export function getNewestEventTagsByKey(evs: TaggedNostrEvent[], tag: string) { const newest = getNewest(evs); if (newest) { const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === tag).map(p => p[1]); @@ -301,7 +301,7 @@ export function getNewestEventTagsByKey(evs: TaggedRawEvent[], tag: string) { } } -export function tagFilterOfTextRepost(note: TaggedRawEvent, id?: u256): (tag: string[], i: number) => boolean { +export function tagFilterOfTextRepost(note: TaggedNostrEvent, id?: u256): (tag: string[], i: number) => boolean { return (tag, i) => tag[0] === "e" && tag[3] === "mention" && note.content === `#[${i}]` && (id ? tag[1] === id : true); } diff --git a/packages/app/src/State/NoteCreator.ts b/packages/app/src/State/NoteCreator.ts index e6a8dda8..ccde3617 100644 --- a/packages/app/src/State/NoteCreator.ts +++ b/packages/app/src/State/NoteCreator.ts @@ -1,5 +1,5 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { NostrEvent, TaggedRawEvent } from "@snort/system"; +import { NostrEvent, TaggedNostrEvent } from "@snort/system"; interface NoteCreatorStore { show: boolean; @@ -7,7 +7,7 @@ interface NoteCreatorStore { error: string; active: boolean; preview?: NostrEvent; - replyTo?: TaggedRawEvent; + replyTo?: TaggedNostrEvent; showAdvanced: boolean; selectedCustomRelays: false | Array; zapForward: string; @@ -47,7 +47,7 @@ const NoteCreatorSlice = createSlice({ setPreview: (state, action: PayloadAction) => { state.preview = action.payload; }, - setReplyTo: (state, action: PayloadAction) => { + setReplyTo: (state, action: PayloadAction) => { state.replyTo = action.payload; }, setShowAdvanced: (state, action: PayloadAction) => { diff --git a/packages/app/src/Wallet/NostrWalletConnect.ts b/packages/app/src/Wallet/NostrWalletConnect.ts index be060730..c42c53a3 100644 --- a/packages/app/src/Wallet/NostrWalletConnect.ts +++ b/packages/app/src/Wallet/NostrWalletConnect.ts @@ -1,4 +1,4 @@ -import { Connection, EventKind, NostrEvent, EventBuilder, EventExt } from "@snort/system"; +import { Connection, EventKind, NostrEvent, EventBuilder, EventExt, PrivateKeySigner } from "@snort/system"; import { LNWallet, WalletError, WalletErrorCode, WalletInfo, WalletInvoice, WalletInvoiceState } from "Wallet"; import debug from "debug"; @@ -163,9 +163,10 @@ export class NostrConnectWallet implements LNWallet { method, params, }); + const signer = new PrivateKeySigner(this.#config.secret); const eb = new EventBuilder(); eb.kind(23194 as EventKind) - .content(await EventExt.encryptDm(payload, this.#config.secret, this.#config.walletPubkey)) + .content(await signer.nip4Encrypt(payload, this.#config.walletPubkey)) .tag(["p", this.#config.walletPubkey]); const evCommand = await eb.buildAndSign(this.#config.secret); @@ -187,7 +188,7 @@ export class NostrConnectWallet implements LNWallet { return await new Promise((resolve, reject) => { this.#commandQueue.set(evCommand.id, { resolve: async (o: string) => { - const reply = JSON.parse(await EventExt.decryptDm(o, this.#config.secret, this.#config.walletPubkey)); + const reply = JSON.parse(await signer.nip4Decrypt(o, this.#config.walletPubkey)); debug("NWC")("%o", reply); resolve(reply); }, diff --git a/packages/app/src/chat/index.ts b/packages/app/src/chat/index.ts index 65b0ce65..6f99fecc 100644 --- a/packages/app/src/chat/index.ts +++ b/packages/app/src/chat/index.ts @@ -2,27 +2,46 @@ import { useSyncExternalStore } from "react"; import { Nip4ChatSystem } from "./nip4"; import { EventKind, EventPublisher, NostrEvent, RequestBuilder, SystemInterface, UserMetadata } from "@snort/system"; import { unwrap } from "@snort/shared"; -import { Chats } from "Cache"; +import { Chats, GiftsCache } from "Cache"; import { findTag, unixNow } from "SnortUtils"; import { Nip29ChatSystem } from "./nip29"; import useModeration from "Hooks/useModeration"; import useLogin from "Hooks/useLogin"; +import { Nip24ChatSystem } from "./nip24"; export enum ChatType { DirectMessage = 1, PublicGroupChat = 2, PrivateGroupChat = 3, + PrivateDirectMessage = 4, +} + +export interface ChatMessage { + id: string; + from: string; + created_at: number; + tags: Array>; + needsDecryption: boolean; + content: string; + decrypt: (pub: EventPublisher) => Promise; +} + +export interface ChatParticipant { + type: "pubkey" | "generic"; + id: string; + profile?: UserMetadata; } export interface Chat { type: ChatType; id: string; + title?: string; unread: number; lastMessage: number; - messages: Array; - profile?: UserMetadata; - createMessage(msg: string, pub: EventPublisher): Promise; - sendMessage(ev: NostrEvent, system: SystemInterface): void | Promise; + participants: Array; + messages: Array; + createMessage(msg: string, pub: EventPublisher): Promise>; + sendMessage(ev: Array, system: SystemInterface): void | Promise; } export interface ChatSystem { @@ -37,6 +56,7 @@ export interface ChatSystem { export const Nip4Chats = new Nip4ChatSystem(Chats); export const Nip29Chats = new Nip29ChatSystem(Chats); +export const Nip24Chats = new Nip24ChatSystem(GiftsCache); /** * Extract the P tag of the event @@ -89,10 +109,18 @@ export function useNip29Chat() { ); } +export function useNip24Chat() { + const { publicKey } = useLogin(); + return useSyncExternalStore( + c => Nip24Chats.hook(c), + () => Nip24Chats.snapshot(publicKey) + ); +} + export function useChatSystem() { const nip4 = useNip4Chat(); - const nip29 = useNip29Chat(); + const nip24 = useNip24Chat(); const { muted, blocked } = useModeration(); - return [...nip4, ...nip29].filter(a => !(muted.includes(a.id) || blocked.includes(a.id))); + return [...nip4, ...nip24].filter(a => !(muted.includes(a.id) || blocked.includes(a.id))); } diff --git a/packages/app/src/chat/nip24.ts b/packages/app/src/chat/nip24.ts new file mode 100644 index 00000000..d65ce9e7 --- /dev/null +++ b/packages/app/src/chat/nip24.ts @@ -0,0 +1,129 @@ +import { ExternalStore, dedupe } from "@snort/shared"; +import { + EventKind, + SystemInterface, + NostrPrefix, + encodeTLVEntries, + TLVEntryType, + TLVEntry, + decodeTLV, +} from "@snort/system"; +import { GiftWrapCache } from "Cache/GiftWrapCache"; +import { UnwrappedGift } from "Db"; +import { Chat, ChatSystem, ChatType, lastReadInChat } from "chat"; + +export class Nip24ChatSystem extends ExternalStore> implements ChatSystem { + #cache: GiftWrapCache; + + constructor(cache: GiftWrapCache) { + super(); + this.#cache = cache; + this.#cache.hook(() => this.notifyChange(), "*"); + } + + subscription() { + // ignored + return undefined; + } + + onEvent() { + // ignored + } + + listChats(pk: string): Chat[] { + const evs = this.#nip24Events(); + const messages = evs.filter(a => a.to === pk); + const chatId = (u: UnwrappedGift) => { + const pTags = dedupe([...(u.tags ?? []).filter(a => a[0] === "p").map(a => a[1]), u.inner.pubkey]) + .sort() + .filter(a => a !== pk); + + return encodeTLVEntries( + "chat24" as NostrPrefix, + ...pTags.map( + v => + ({ + value: v, + type: TLVEntryType.Author, + length: v.length, + } as TLVEntry) + ) + ); + }; + return dedupe(messages.map(a => chatId(a))).map(a => { + const chatMessages = messages.filter(b => chatId(b) === a); + return Nip24ChatSystem.createChatObj(a, chatMessages); + }); + } + + static createChatObj(id: string, messages: Array) { + const last = lastReadInChat(id); + const participants = decodeTLV(id) + .filter(v => v.type === TLVEntryType.Author) + .map(v => ({ + type: "pubkey", + id: v.value as string, + })); + const title = messages.reduce( + (acc, v) => { + const sbj = v.tags?.find(a => a[0] === "subject")?.[1]; + if (v.created_at > acc.t && sbj) { + acc.title = sbj; + acc.t = v.created_at; + } + return acc; + }, + { + t: 0, + title: "", + } + ); + return { + type: ChatType.PrivateDirectMessage, + id, + title: title.title, + unread: messages.reduce((acc, v) => (v.created_at > last ? acc++ : acc), 0), + lastMessage: messages.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0), + participants, + messages: messages.map(m => ({ + id: m.id, + created_at: m.created_at, + from: m.inner.pubkey, + tags: m.tags, + content: "", + needsDecryption: true, + decrypt: async pub => { + return await pub.decryptDm(m.inner); + }, + })), + createMessage: async (msg, pub) => { + const gossip = pub.createUnsigned(EventKind.ChatRumor, msg, eb => { + for (const pt of participants) { + eb.tag(["p", pt.id]); + } + return eb; + }); + const messages = []; + for (const pt of participants) { + const recvSealedN = await pub.giftWrap(await pub.sealRumor(gossip, pt.id), pt.id); + messages.push(recvSealedN); + } + const sendSealed = await pub.giftWrap(await pub.sealRumor(gossip, pub.pubKey), pub.pubKey); + return [...messages, sendSealed]; + }, + sendMessage: (ev, system: SystemInterface) => { + console.debug(ev); + ev.forEach(a => system.BroadcastEvent(a)); + }, + } as Chat; + } + + takeSnapshot(p: string): Chat[] { + return this.listChats(p); + } + + #nip24Events() { + const sn = this.#cache.takeSnapshot(); + return sn.filter(a => a.inner.kind === EventKind.SealedRumor); + } +} diff --git a/packages/app/src/chat/nip28.ts b/packages/app/src/chat/nip28.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/app/src/chat/nip29.ts b/packages/app/src/chat/nip29.ts index c9830c3d..94964cba 100644 --- a/packages/app/src/chat/nip29.ts +++ b/packages/app/src/chat/nip29.ts @@ -57,19 +57,41 @@ export class Nip29ChatSystem extends ExternalStore> implements ChatS return { type: ChatType.PublicGroupChat, id: g, + title: `${relay}/${channel}`, unread: messages.reduce((acc, v) => (v.created_at > lastRead ? acc++ : acc), 0), lastMessage: messages.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0), - messages, - createMessage: (msg, pub) => { - return pub.generic(eb => { - return eb - .kind(EventKind.SimpleChatMessage) - .tag(["g", `/${channel}`, relay]) - .content(msg); - }); + messages: messages.map(m => ({ + id: m.id, + created_at: m.created_at, + from: m.pubkey, + tags: m.tags, + needsDecryption: false, + content: m.content, + decrypt: async () => { + return m.content; + }, + })), + participants: [ + { + type: "generic", + id: "", + profile: { + name: `${relay}/${channel}`, + }, + }, + ], + createMessage: async (msg, pub) => { + return [ + await pub.generic(eb => { + return eb + .kind(EventKind.SimpleChatMessage) + .tag(["g", `/${channel}`, relay]) + .content(msg); + }), + ]; }, - sendMessage: async (ev: NostrEvent, system: SystemInterface) => { - await system.WriteOnceToRelay(`wss://${relay}`, ev); + sendMessage: async (ev, system: SystemInterface) => { + ev.forEach(async a => await system.WriteOnceToRelay(`wss://${relay}`, a)); }, } as Chat; }); diff --git a/packages/app/src/chat/nip4.ts b/packages/app/src/chat/nip4.ts index 71793ac3..2a8b80ef 100644 --- a/packages/app/src/chat/nip4.ts +++ b/packages/app/src/chat/nip4.ts @@ -1,5 +1,14 @@ import { ExternalStore, FeedCache, dedupe } from "@snort/shared"; -import { EventKind, NostrEvent, RequestBuilder, SystemInterface } from "@snort/system"; +import { + EventKind, + NostrEvent, + NostrPrefix, + RequestBuilder, + SystemInterface, + TLVEntryType, + decodeTLV, + encodeTLVEntries, +} from "@snort/system"; import { Chat, ChatSystem, ChatType, inChatWith, lastReadInChat, selfChat } from "chat"; import { debug } from "debug"; @@ -40,27 +49,50 @@ export class Nip4ChatSystem extends ExternalStore> implements ChatSy listChats(pk: string): Chat[] { const myDms = this.#nip4Events(); - return dedupe(myDms.map(a => inChatWith(a, pk))).map(a => { - const messages = myDms.filter( - b => (a === pk && selfChat(b, pk)) || (!selfChat(b, pk) && inChatWith(b, pk) === a) - ); + const chatId = (a: NostrEvent) => { + return encodeTLVEntries("chat4" as NostrPrefix, { + type: TLVEntryType.Author, + value: inChatWith(a, pk), + length: 0, + }); + }; + + return dedupe(myDms.map(chatId)).map(a => { + const messages = myDms.filter(b => chatId(b) === a); return Nip4ChatSystem.createChatObj(a, messages); }); } static createChatObj(id: string, messages: Array) { const last = lastReadInChat(id); + const pk = decodeTLV(id).find(a => a.type === TLVEntryType.Author)?.value as string; return { type: ChatType.DirectMessage, id, unread: messages.reduce((acc, v) => (v.created_at > last ? acc++ : acc), 0), lastMessage: messages.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0), - messages, - createMessage: (msg, pub) => { - return pub.sendDm(msg, id); + participants: [ + { + type: "pubkey", + id: pk, + }, + ], + messages: messages.map(m => ({ + id: m.id, + created_at: m.created_at, + from: m.pubkey, + tags: m.tags, + content: "", + needsDecryption: true, + decrypt: async pub => { + return await pub.decryptDm(m); + }, + })), + createMessage: async (msg, pub) => { + return [await pub.sendDm(msg, pk)]; }, - sendMessage: (ev: NostrEvent, system: SystemInterface) => { - system.BroadcastEvent(ev); + sendMessage: (ev, system: SystemInterface) => { + ev.forEach(a => system.BroadcastEvent(a)); }, } as Chat; } diff --git a/packages/system-react/README.md b/packages/system-react/README.md index fbb237b1..33cd694e 100644 --- a/packages/system-react/README.md +++ b/packages/system-react/README.md @@ -7,7 +7,7 @@ Sample: ```js import { useMemo } from "react"; import { useRequestBuilder, useUserProfile } from "@snort/system-react"; -import { FlatNoteStore, NostrSystem, RequestBuilder, TaggedRawEvent } from "@snort/system"; +import { FlatNoteStore, NostrSystem, RequestBuilder, TaggedNostrEvent } from "@snort/system"; // singleton nostr system class const System = new NostrSystem({}); @@ -15,7 +15,7 @@ const System = new NostrSystem({}); // some bootstrap relays ["wss://relay.snort.social", "wss://nos.lol"].forEach(r => System.ConnectToRelay(r, { read: true, write: false })); -export function Note({ ev }: { ev: TaggedRawEvent }) { +export function Note({ ev }: { ev: TaggedNostrEvent }) { // get profile from cache or request a profile from relays const profile = useUserProfile(System, ev.pubkey); diff --git a/packages/system-react/example/example.tsx b/packages/system-react/example/example.tsx index 04086e68..75226582 100644 --- a/packages/system-react/example/example.tsx +++ b/packages/system-react/example/example.tsx @@ -1,14 +1,14 @@ import { useMemo } from "react"; import { useRequestBuilder, useUserProfile } from "../src"; -import { FlatNoteStore, NostrSystem, RequestBuilder, TaggedRawEvent } from "@snort/system"; +import { FlatNoteStore, NostrSystem, RequestBuilder, TaggedNostrEvent } from "@snort/system"; const System = new NostrSystem({}); // some bootstrap relays ["wss://relay.snort.social", "wss://nos.lol"].forEach(r => System.ConnectToRelay(r, { read: true, write: false })); -export function Note({ ev }: { ev: TaggedRawEvent }) { +export function Note({ ev }: { ev: TaggedNostrEvent }) { const profile = useUserProfile(System, ev.pubkey); return ( diff --git a/packages/system/src/connection.ts b/packages/system/src/connection.ts index 517faeb7..cd614f19 100644 --- a/packages/system/src/connection.ts +++ b/packages/system/src/connection.ts @@ -4,7 +4,7 @@ import { unwrap, ExternalStore, unixNowMs } from "@snort/shared"; import { DefaultConnectTimeout } from "./const"; import { ConnectionStats } from "./connection-stats"; -import { NostrEvent, ReqCommand, TaggedRawEvent, u256 } from "./nostr"; +import { NostrEvent, ReqCommand, TaggedNostrEvent, u256 } from "./nostr"; import { RelayInfo } from "./relay-info"; export type AuthHandler = (challenge: string, relay: string) => Promise; @@ -62,7 +62,7 @@ export class Connection extends ExternalStore { ReconnectTimer?: ReturnType; EventsCallback: Map void>; OnConnected?: (wasReconnect: boolean) => void; - OnEvent?: (sub: string, e: TaggedRawEvent) => void; + OnEvent?: (sub: string, e: TaggedNostrEvent) => void; OnEose?: (sub: string) => void; OnDisconnect?: (code: number) => void; Auth?: AuthHandler; diff --git a/packages/system/src/event-ext.ts b/packages/system/src/event-ext.ts index af4c6b40..c8d6bacc 100644 --- a/packages/system/src/event-ext.ts +++ b/packages/system/src/event-ext.ts @@ -2,7 +2,7 @@ import * as secp from "@noble/curves/secp256k1"; import * as utils from "@noble/curves/abstract/utils"; import { getPublicKey, sha256, unixNow } from "@snort/shared"; -import { EventKind, HexKey, NostrEvent } from "."; +import { EventKind, HexKey, NostrEvent, NotSignedNostrEvent } from "."; import { Nip4WebCryptoEncryptor } from "./impl/nip4"; export interface Tag { @@ -56,7 +56,7 @@ export abstract class EventExt { return result; } - static createId(e: NostrEvent) { + static createId(e: NostrEvent | NotSignedNostrEvent) { const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content]; const hash = sha256(JSON.stringify(payload)); @@ -136,16 +136,4 @@ export abstract class EventExt { ret.pubKeys = Array.from(new Set(ev.tags.filter(a => a[0] === "p").map(a => a[1]))); return ret; } - - static async decryptDm(content: string, privkey: HexKey, pubkey: HexKey) { - const enc = new Nip4WebCryptoEncryptor(); - const key = enc.getSharedSecret(privkey, pubkey); - return await enc.decryptData(content, key); - } - - static async encryptDm(content: string, privKey: HexKey, pubKey: HexKey) { - const enc = new Nip4WebCryptoEncryptor(); - const secret = enc.getSharedSecret(privKey, pubKey); - return await enc.encryptData(content, secret); - } } diff --git a/packages/system/src/event-kind.ts b/packages/system/src/event-kind.ts index af6bdff4..72a7e03d 100644 --- a/packages/system/src/event-kind.ts +++ b/packages/system/src/event-kind.ts @@ -10,6 +10,8 @@ enum EventKind { Reaction = 7, // NIP-25 BadgeAward = 8, // NIP-58 SimpleChatMessage = 9, // NIP-29 + SealedRumor = 13, // NIP-59 + ChatRumor = 14, // NIP-24 SnortSubscriptions = 1000, // NIP-XX Polls = 6969, // NIP-69 GiftWrap = 1059, // NIP-59 diff --git a/packages/system/src/event-publisher.ts b/packages/system/src/event-publisher.ts index fef65069..60186736 100644 --- a/packages/system/src/event-publisher.ts +++ b/packages/system/src/event-publisher.ts @@ -1,16 +1,18 @@ import * as secp from "@noble/curves/secp256k1"; import * as utils from "@noble/curves/abstract/utils"; -import { unwrap, getPublicKey } from "@snort/shared"; +import { unwrap, getPublicKey, unixNow } from "@snort/shared"; import { EventKind, + EventSigner, FullRelaySettings, HexKey, Lists, - Nip44Encryptor, NostrEvent, + NotSignedNostrEvent, + PrivateKeySigner, RelaySettings, - TaggedRawEvent, + TaggedNostrEvent, u256, UserMetadata, } from "."; @@ -22,53 +24,6 @@ import { Nip7Signer } from "./impl/nip7"; type EventBuilderHook = (ev: EventBuilder) => EventBuilder; -export interface EventSigner { - init(): Promise; - getPubKey(): Promise | string; - nip4Encrypt(content: string, key: string): Promise; - nip4Decrypt(content: string, otherKey: string): Promise; - sign(ev: NostrEvent): Promise; -} - -export class PrivateKeySigner implements EventSigner { - #publicKey: string; - #privateKey: string; - - constructor(privateKey: string | Uint8Array) { - if (typeof privateKey === "string") { - this.#privateKey = privateKey; - } else { - this.#privateKey = utils.bytesToHex(privateKey); - } - this.#publicKey = getPublicKey(this.#privateKey); - } - - get privateKey() { - return this.#privateKey; - } - - init(): Promise { - return Promise.resolve(); - } - - getPubKey(): string { - return this.#publicKey; - } - - async nip4Encrypt(content: string, key: string): Promise { - return await EventExt.encryptDm(content, this.#privateKey, key); - } - - async nip4Decrypt(content: string, otherKey: string): Promise { - return await EventExt.decryptDm(content, this.#privateKey, otherKey); - } - - sign(ev: NostrEvent): Promise { - EventExt.sign(ev, this.#privateKey); - return Promise.resolve(ev); - } -} - export class EventPublisher { #pubKey: string; #signer: EventSigner; @@ -209,7 +164,7 @@ export class EventPublisher { /** * Reply to a note */ - async reply(replyTo: TaggedRawEvent, msg: string, fnExtra?: EventBuilderHook) { + async reply(replyTo: TaggedNostrEvent, msg: string, fnExtra?: EventBuilderHook) { const eb = this.#eb(EventKind.TextNote); eb.content(msg); @@ -298,7 +253,15 @@ export class EventPublisher { } async decryptDm(note: NostrEvent) { - if (note.pubkey !== this.#pubKey && !note.tags.some(a => a[1] === this.#pubKey)) { + if (note.kind === EventKind.SealedRumor) { + const unseal = await this.unsealRumor(note); + return unseal.content; + } + if ( + note.kind === EventKind.DirectMessage && + 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; @@ -322,21 +285,54 @@ export class EventPublisher { /** * NIP-59 Gift Wrap event with ephemeral key */ - async giftWrap(inner: NostrEvent) { + async giftWrap(inner: NostrEvent, explicitP?: string) { const secret = utils.bytesToHex(secp.secp256k1.utils.randomPrivateKey()); + const signer = new PrivateKeySigner(secret); - const pTag = findTag(inner, "p"); + const pTag = explicitP ?? findTag(inner, "p"); if (!pTag) throw new Error("Inner event must have a p tag"); const eb = new EventBuilder(); - eb.pubKey(getPublicKey(secret)); + eb.pubKey(signer.getPubKey()); eb.kind(EventKind.GiftWrap); eb.tag(["p", pTag]); - - const enc = new Nip44Encryptor(); - const shared = enc.getSharedSecret(secret, pTag); - eb.content(enc.encryptData(JSON.stringify(inner), shared)); + eb.content(await signer.nip44Encrypt(JSON.stringify(inner), pTag)); return await eb.buildAndSign(secret); } + + async unwrapGift(gift: NostrEvent) { + const body = await this.#signer.nip44Decrypt(gift.content, gift.pubkey); + return JSON.parse(body) as NostrEvent; + } + + /** + * Create an unsigned gossip message + */ + createUnsigned(kind: EventKind, content: string, fnHook: EventBuilderHook) { + const eb = new EventBuilder(); + eb.pubKey(this.pubKey); + eb.kind(kind); + eb.content(content); + fnHook(eb); + return eb.build() as NotSignedNostrEvent; + } + + /** + * Create sealed rumor + */ + async sealRumor(inner: NotSignedNostrEvent, toKey: string) { + const eb = this.#eb(EventKind.SealedRumor); + eb.content(await this.#signer.nip44Encrypt(JSON.stringify(inner), toKey)); + return await this.#sign(eb); + } + + /** + * Unseal rumor + */ + async unsealRumor(inner: NostrEvent) { + if (inner.kind !== EventKind.SealedRumor) throw new Error("Not a sealed rumor event"); + const body = await this.#signer.nip44Decrypt(inner.content, inner.pubkey); + return JSON.parse(body) as NostrEvent; + } } diff --git a/packages/system/src/impl/nip4.ts b/packages/system/src/impl/nip4.ts index 8a4a6b95..016de9e0 100644 --- a/packages/system/src/impl/nip4.ts +++ b/packages/system/src/impl/nip4.ts @@ -1,4 +1,4 @@ -import { MessageEncryptor } from "index"; +import { MessageEncryptor, MessageEncryptorPayload, MessageEncryptorVersion } from "index"; import { base64 } from "@scure/base"; import { secp256k1 } from "@noble/curves/secp256k1"; @@ -22,26 +22,22 @@ export class Nip4WebCryptoEncryptor implements MessageEncryptor { key, data ); - const uData = new Uint8Array(result); - return `${base64.encode(uData)}?iv=${base64.encode(iv)}`; + return { + ciphertext: new Uint8Array(result), + nonce: iv, + v: MessageEncryptorVersion.Nip4 + } as MessageEncryptorPayload; } - - /** - * Decrypt the content of the message - */ - async decryptData(cyphertext: string, sharedSecet: Uint8Array) { + + async decryptData(payload: MessageEncryptorPayload, sharedSecet: Uint8Array) { const key = await this.#importKey(sharedSecet); - const cSplit = cyphertext.split("?iv="); - const data = base64.decode(cSplit[0]); - const iv = base64.decode(cSplit[1]); - const result = await window.crypto.subtle.decrypt( { name: "AES-CBC", - iv: iv, + iv: payload.nonce, }, key, - data + payload.ciphertext ); return new TextDecoder().decode(result); } diff --git a/packages/system/src/impl/nip44.ts b/packages/system/src/impl/nip44.ts index 77050658..cf40f7c1 100644 --- a/packages/system/src/impl/nip44.ts +++ b/packages/system/src/impl/nip44.ts @@ -1,4 +1,4 @@ -import { MessageEncryptor } from "index"; +import { MessageEncryptor, MessageEncryptorPayload, MessageEncryptorVersion } from "index"; import { base64 } from "@scure/base"; import { randomBytes } from "@noble/hashes/utils"; @@ -6,12 +6,7 @@ import { streamXOR as xchacha20 } from "@stablelib/xchacha20"; import { secp256k1 } from "@noble/curves/secp256k1"; import { sha256 } from "@noble/hashes/sha256"; -export enum Nip44Version { - Reserved = 0x00, - XChaCha20 = 0x01, -} - -export class Nip44Encryptor implements MessageEncryptor { +export class XChaCha20Encryptor implements MessageEncryptor { getSharedSecret(privateKey: string, publicKey: string) { const key = secp256k1.getSharedSecret(privateKey, "02" + publicKey); return sha256(key.slice(1, 33)); @@ -21,19 +16,18 @@ export class Nip44Encryptor implements MessageEncryptor { const nonce = randomBytes(24); const plaintext = new TextEncoder().encode(content); const ciphertext = xchacha20(sharedSecret, nonce, plaintext, plaintext); - const ctb64 = base64.encode(Uint8Array.from(ciphertext)); - const nonceb64 = base64.encode(nonce); - return JSON.stringify({ ciphertext: ctb64, nonce: nonceb64, v: Nip44Version.XChaCha20 }); + return { + ciphertext: Uint8Array.from(ciphertext), + nonce: nonce, + v: MessageEncryptorVersion.XChaCha20, + } as MessageEncryptorPayload; } - decryptData(cyphertext: string, sharedSecret: Uint8Array) { - const dt = JSON.parse(cyphertext); - if (dt.v !== 1) throw new Error("NIP44: unknown encryption version"); + decryptData(payload: MessageEncryptorPayload, sharedSecret: Uint8Array) { + if (payload.v !== MessageEncryptorVersion.XChaCha20) throw new Error("NIP44: wrong encryption version"); - const ciphertext = base64.decode(dt.ciphertext); - const nonce = base64.decode(dt.nonce); - const plaintext = xchacha20(sharedSecret, nonce, ciphertext, ciphertext); - const text = new TextDecoder().decode(plaintext); - return text; + const dst = xchacha20(sharedSecret, payload.nonce, payload.ciphertext, payload.ciphertext); + const decoded = new TextDecoder().decode(dst); + return decoded; } } diff --git a/packages/system/src/impl/nip46.ts b/packages/system/src/impl/nip46.ts index b42e99cb..c2b3e276 100644 --- a/packages/system/src/impl/nip46.ts +++ b/packages/system/src/impl/nip46.ts @@ -4,7 +4,7 @@ import { v4 as uuid } from "uuid"; import debug from "debug"; import { Connection } from "../connection"; -import { EventSigner, PrivateKeySigner } from "../event-publisher"; +import { EventSigner, PrivateKeySigner } from "../signer"; import { NostrEvent } from "../nostr"; import { EventBuilder } from "../event-builder"; import EventKind from "../event-kind"; @@ -138,6 +138,14 @@ export class Nip46Signer implements EventSigner { return await this.#rpc("nip04_decrypt", [otherKey, content]); } + nip44Encrypt(content: string, key: string): Promise { + throw new Error("Method not implemented."); + } + + nip44Decrypt(content: string, otherKey: string): Promise { + throw new Error("Method not implemented."); + } + async sign(ev: NostrEvent) { const evStr = await this.#rpc("sign_event", [JSON.stringify(ev)]); return JSON.parse(evStr); diff --git a/packages/system/src/impl/nip7.ts b/packages/system/src/impl/nip7.ts index 1114916c..74ec83ae 100644 --- a/packages/system/src/impl/nip7.ts +++ b/packages/system/src/impl/nip7.ts @@ -1,6 +1,5 @@ import { WorkQueueItem, processWorkQueue, barrierQueue, unwrap } from "@snort/shared"; -import { EventSigner } from "../event-publisher"; -import { HexKey, NostrEvent } from "../nostr"; +import { EventSigner, HexKey, NostrEvent } from ".."; const Nip7Queue: Array = []; processWorkQueue(Nip7Queue); @@ -51,6 +50,14 @@ export class Nip7Signer implements EventSigner { ); } + async nip44Encrypt(content: string, key: string): Promise { + throw new Error("Method not implemented."); + } + + async nip44Decrypt(content: string, otherKey: string): Promise { + throw new Error("Method not implemented."); + } + async sign(ev: NostrEvent): Promise { if (!window.nostr) { throw new Error("Cannot use NIP-07 signer, not found!"); diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index 677dce48..2c6e00c1 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -19,6 +19,7 @@ export * from "./event-builder"; export * from "./nostr-link"; export * from "./profile-cache"; export * from "./zaps"; +export * from "./signer"; export * from "./impl/nip4"; export * from "./impl/nip44"; @@ -52,8 +53,19 @@ export interface SystemSnapshot { }>; } +export const enum MessageEncryptorVersion { + Nip4 = 0, + XChaCha20 = 1, +} + +export interface MessageEncryptorPayload { + ciphertext: Uint8Array, + nonce: Uint8Array, + v: MessageEncryptorVersion +} + export interface MessageEncryptor { getSharedSecret(privateKey: string, publicKey: string): Promise | Uint8Array; - encryptData(plaintext: string, sharedSecet: Uint8Array): Promise | string; - decryptData(cyphertext: string, sharedSecet: Uint8Array): Promise | string; + encryptData(plaintext: string, sharedSecet: Uint8Array): Promise | MessageEncryptorPayload; + decryptData(payload: MessageEncryptorPayload, sharedSecet: Uint8Array): Promise | string; } diff --git a/packages/system/src/links.ts b/packages/system/src/links.ts index 0e385f74..5a8a230c 100644 --- a/packages/system/src/links.ts +++ b/packages/system/src/links.ts @@ -46,6 +46,38 @@ export function encodeTLV(prefix: NostrPrefix, id: string, relays?: string[], ki return bech32.encode(prefix, bech32.toWords(new Uint8Array([...tl0, ...tl1, ...tl2, ...tl3])), 1_000); } +export function encodeTLVEntries(prefix: NostrPrefix, ...entries: Array) { + const enc = new TextEncoder(); + const buffers: Array = []; + + for (const v of entries) { + switch (v.type) { + case TLVEntryType.Special: { + const buf = + prefix === NostrPrefix.Address ? enc.encode(v.value as string) : utils.hexToBytes(v.value as string); + buffers.push(0, buf.length, ...buf); + break; + } + case TLVEntryType.Relay: { + const data = enc.encode(v.value as string); + buffers.push(1, data.length, ...data); + break; + } + case TLVEntryType.Author: { + if ((v.value as string).length !== 64) throw new Error("Author must be 32 bytes"); + buffers.push(2, 32, ...utils.hexToBytes(v.value as string)); + break; + } + case TLVEntryType.Kind: { + if (typeof v.value !== "number") throw new Error("Kind must be a number"); + buffers.push(3, 4, ...new Uint8Array(new Uint32Array([v.value as number]).buffer).reverse()); + break; + } + } + } + return bech32.encode(prefix, bech32.toWords(new Uint8Array(buffers)), 1_000); +} + export function decodeTLV(str: string) { const decoded = bech32.decode(str, 1_000); const data = bech32.fromWords(decoded.words); diff --git a/packages/system/src/nostr-system.ts b/packages/system/src/nostr-system.ts index 118fde5b..7145e9e0 100644 --- a/packages/system/src/nostr-system.ts +++ b/packages/system/src/nostr-system.ts @@ -1,7 +1,7 @@ import debug from "debug"; import { unwrap, sanitizeRelayUrl, ExternalStore, FeedCache } from "@snort/shared"; -import { NostrEvent, TaggedRawEvent } from "./nostr"; +import { NostrEvent, TaggedNostrEvent } from "./nostr"; import { AuthHandler, Connection, RelaySettings, ConnectionStateSnapshot } from "./connection"; import { Query } from "./query"; import { NoteStore } from "./note-collection"; @@ -147,7 +147,7 @@ export class NostrSystem extends ExternalStore implements System } } - OnEvent(sub: string, ev: TaggedRawEvent) { + OnEvent(sub: string, ev: TaggedNostrEvent) { for (const [, v] of this.Queries) { v.onEvent(sub, ev); } diff --git a/packages/system/src/nostr.ts b/packages/system/src/nostr.ts index c818255f..7ea45b80 100644 --- a/packages/system/src/nostr.ts +++ b/packages/system/src/nostr.ts @@ -10,7 +10,7 @@ export interface NostrEvent { sig: string; } -export interface TaggedRawEvent extends NostrEvent { +export interface TaggedNostrEvent extends NostrEvent { /** * A list of relays this event was seen on */ @@ -85,3 +85,5 @@ export interface FullRelaySettings { url: string; settings: RelaySettings; } + +export type NotSignedNostrEvent = Omit; \ No newline at end of file diff --git a/packages/system/src/note-collection.ts b/packages/system/src/note-collection.ts index 1137e869..859bae85 100644 --- a/packages/system/src/note-collection.ts +++ b/packages/system/src/note-collection.ts @@ -1,12 +1,12 @@ import { appendDedupe } from "@snort/shared"; -import { TaggedRawEvent, u256 } from "."; +import { TaggedNostrEvent, u256 } from "."; import { findTag } from "./utils"; export interface StoreSnapshot { data: TSnapshot | undefined; clear: () => void; loading: () => boolean; - add: (ev: Readonly | Readonly>) => void; + add: (ev: Readonly | Readonly>) => void; } export const EmptySnapshot = { @@ -20,10 +20,10 @@ export const EmptySnapshot = { }, } as StoreSnapshot; -export type NoteStoreSnapshotData = Readonly> | Readonly; +export type NoteStoreSnapshotData = Readonly> | Readonly; export type NoteStoreHook = () => void; export type NoteStoreHookRelease = () => void; -export type OnEventCallback = (e: Readonly>) => void; +export type OnEventCallback = (e: Readonly>) => void; export type OnEventCallbackRelease = () => void; export type OnEoseCallback = (c: string) => void; export type OnEoseCallbackRelease = () => void; @@ -32,7 +32,7 @@ export type OnEoseCallbackRelease = () => void; * Generic note store interface */ export abstract class NoteStore { - abstract add(ev: Readonly | Readonly>): void; + abstract add(ev: Readonly | Readonly>): void; abstract clear(): void; // react hooks @@ -74,7 +74,7 @@ export abstract class HookedNoteStore i this.onChange([]); } - abstract add(ev: Readonly | Readonly>): void; + abstract add(ev: Readonly | Readonly>): void; abstract clear(): void; hook(cb: NoteStoreHook): NoteStoreHookRelease { @@ -106,7 +106,7 @@ export abstract class HookedNoteStore i protected abstract takeSnapshot(): TSnapshot | undefined; - protected onChange(changes: Readonly>): void { + protected onChange(changes: Readonly>): void { this.#needsSnapshot = true; if (!this.#nextNotifyTimer) { this.#nextNotifyTimer = setTimeout(() => { @@ -137,13 +137,13 @@ export abstract class HookedNoteStore i /** * A simple flat container of events with no duplicates */ -export class FlatNoteStore extends HookedNoteStore>> { - #events: Array = []; +export class FlatNoteStore extends HookedNoteStore>> { + #events: Array = []; #ids: Set = new Set(); - add(ev: TaggedRawEvent | Array) { + add(ev: TaggedNostrEvent | Array) { ev = Array.isArray(ev) ? ev : [ev]; - const changes: Array = []; + const changes: Array = []; ev.forEach(a => { if (!this.#ids.has(a.id)) { this.#events.push(a); @@ -176,18 +176,18 @@ export class FlatNoteStore extends HookedNoteStore>> { - #keyFn: (ev: TaggedRawEvent) => string; - #events: Map = new Map(); +export class KeyedReplaceableNoteStore extends HookedNoteStore>> { + #keyFn: (ev: TaggedNostrEvent) => string; + #events: Map = new Map(); - constructor(fn: (ev: TaggedRawEvent) => string) { + constructor(fn: (ev: TaggedNostrEvent) => string) { super(); this.#keyFn = fn; } - add(ev: TaggedRawEvent | Array) { + add(ev: TaggedNostrEvent | Array) { ev = Array.isArray(ev) ? ev : [ev]; - const changes: Array = []; + const changes: Array = []; ev.forEach(a => { const keyOnEvent = this.#keyFn(a); const existingCreated = this.#events.get(keyOnEvent)?.created_at ?? 0; @@ -214,12 +214,12 @@ export class KeyedReplaceableNoteStore extends HookedNoteStore> { - #event?: TaggedRawEvent; +export class ReplaceableNoteStore extends HookedNoteStore> { + #event?: TaggedNostrEvent; - add(ev: TaggedRawEvent | Array) { + add(ev: TaggedNostrEvent | Array) { ev = Array.isArray(ev) ? ev : [ev]; - const changes: Array = []; + const changes: Array = []; ev.forEach(a => { const existingCreated = this.#event?.created_at ?? 0; if (a.created_at > existingCreated) { diff --git a/packages/system/src/profile-cache.ts b/packages/system/src/profile-cache.ts index 7f534fff..e453d866 100644 --- a/packages/system/src/profile-cache.ts +++ b/packages/system/src/profile-cache.ts @@ -1,6 +1,6 @@ import debug from "debug"; import { unixNowMs, FeedCache } from "@snort/shared"; -import { EventKind, HexKey, SystemInterface, TaggedRawEvent, NoteCollection, RequestBuilder } from "."; +import { EventKind, HexKey, SystemInterface, TaggedNostrEvent, NoteCollection, RequestBuilder } from "."; import { ProfileCacheExpire } from "./const"; import { mapEventToProfile, MetadataCache } from "./cache"; @@ -57,7 +57,7 @@ export class ProfileLoaderService { } } - async onProfileEvent(e: Readonly) { + async onProfileEvent(e: Readonly) { const profile = mapEventToProfile(e); if (profile) { await this.#cache.update(profile); @@ -101,7 +101,7 @@ export class ProfileLoaderService { await this.onProfileEvent(pe); } }); - const results = await new Promise>>(resolve => { + const results = await new Promise>>(resolve => { let timeout: ReturnType | undefined = undefined; const release = feed.hook(() => { if (!feed.loading) { diff --git a/packages/system/src/query.ts b/packages/system/src/query.ts index fce2fc19..d7661a12 100644 --- a/packages/system/src/query.ts +++ b/packages/system/src/query.ts @@ -2,7 +2,7 @@ import { v4 as uuid } from "uuid"; import debug from "debug"; import { unixNowMs, unwrap } from "@snort/shared"; -import { Connection, ReqFilter, Nips, TaggedRawEvent } from "."; +import { Connection, ReqFilter, Nips, TaggedNostrEvent } from "."; import { NoteStore } from "./note-collection"; import { flatMerge } from "./request-merger"; import { BuiltRawReqFilter } from "./request-builder"; @@ -176,7 +176,7 @@ export class Query implements QueryBase { return this.#feed; } - onEvent(sub: string, e: TaggedRawEvent) { + onEvent(sub: string, e: TaggedNostrEvent) { for (const t of this.#tracing) { if (t.id === sub) { this.feed.add(e); diff --git a/packages/system/src/signer.ts b/packages/system/src/signer.ts new file mode 100644 index 00000000..19651828 --- /dev/null +++ b/packages/system/src/signer.ts @@ -0,0 +1,108 @@ +import { bytesToHex } from "@noble/curves/abstract/utils"; +import { getPublicKey } from "@snort/shared"; +import { EventExt } from "./event-ext"; +import { Nip4WebCryptoEncryptor } from "./impl/nip4"; +import { XChaCha20Encryptor } from "./impl/nip44"; +import { MessageEncryptorPayload, MessageEncryptorVersion } from "./index"; +import { NostrEvent } from "./nostr"; +import { base64 } from "@scure/base"; + +export interface EventSigner { + init(): Promise; + getPubKey(): Promise | string; + nip4Encrypt(content: string, key: string): Promise; + nip4Decrypt(content: string, otherKey: string): Promise; + nip44Encrypt(content: string, key: string): Promise; + nip44Decrypt(content: string, otherKey: string): Promise; + sign(ev: NostrEvent): Promise; +} + +export class PrivateKeySigner implements EventSigner { + #publicKey: string; + #privateKey: string; + + constructor(privateKey: string | Uint8Array) { + if (typeof privateKey === "string") { + this.#privateKey = privateKey; + } else { + this.#privateKey = bytesToHex(privateKey); + } + this.#publicKey = getPublicKey(this.#privateKey); + } + + get privateKey() { + return this.#privateKey; + } + + init(): Promise { + return Promise.resolve(); + } + + getPubKey(): string { + return this.#publicKey; + } + + async nip4Encrypt(content: string, key: string) { + const enc = new Nip4WebCryptoEncryptor(); + const secret = enc.getSharedSecret(this.privateKey, key); + const data = await enc.encryptData(content, secret); + return `${base64.encode(data.ciphertext)}?iv=${base64.encode(data.nonce)}`; + } + + async nip4Decrypt(content: string, otherKey: string) { + const enc = new Nip4WebCryptoEncryptor(); + const secret = enc.getSharedSecret(this.privateKey, otherKey); + const [ciphertext, iv] = content.split("?iv="); + return await enc.decryptData( + { + ciphertext: base64.decode(ciphertext), + nonce: base64.decode(iv), + v: MessageEncryptorVersion.Nip4, + }, + secret + ); + } + + async nip44Encrypt(content: string, key: string) { + const enc = new XChaCha20Encryptor(); + const shared = enc.getSharedSecret(this.#privateKey, key); + const data = enc.encryptData(content, shared); + return this.#encodePayload(data); + } + + async nip44Decrypt(content: string, otherKey: string) { + const payload = this.#decodePayload(content); + if (payload.v !== MessageEncryptorVersion.XChaCha20) throw new Error("Invalid payload version"); + + const enc = new XChaCha20Encryptor(); + const shared = enc.getSharedSecret(this.#privateKey, otherKey); + return enc.decryptData(payload, shared); + } + + #decodePayload(p: string) { + if (p.startsWith("{") && p.endsWith("}")) { + const pj = JSON.parse(p) as { v: number; nonce: string; ciphertext: string }; + return { + v: pj.v, + nonce: base64.decode(pj.nonce), + ciphertext: base64.decode(pj.ciphertext), + } as MessageEncryptorPayload; + } else { + const buf = base64.decode(p); + return { + v: buf[0], + nonce: buf.subarray(1, 25), + ciphertext: buf.subarray(25), + } as MessageEncryptorPayload; + } + } + + #encodePayload(p: MessageEncryptorPayload) { + return base64.encode(new Uint8Array([p.v, ...p.nonce, ...p.ciphertext])); + } + + sign(ev: NostrEvent): Promise { + EventExt.sign(ev, this.#privateKey); + return Promise.resolve(ev); + } +} diff --git a/packages/system/tests/note-collection.test.ts b/packages/system/tests/note-collection.test.ts index 9d4223ac..4d78150c 100644 --- a/packages/system/tests/note-collection.test.ts +++ b/packages/system/tests/note-collection.test.ts @@ -1,24 +1,24 @@ -import { TaggedRawEvent } from "../src/nostr"; +import { TaggedNostrEvent } from "../src/nostr"; import { describe, expect } from "@jest/globals"; import { FlatNoteStore, ReplaceableNoteStore } from "../src/note-collection"; describe("NoteStore", () => { describe("flat", () => { test("one event", () => { - const ev = { id: "one" } as TaggedRawEvent; + const ev = { id: "one" } as TaggedNostrEvent; const c = new FlatNoteStore(); c.add(ev); expect(c.getSnapshotData()).toEqual([ev]); }); test("still one event", () => { - const ev = { id: "one" } as TaggedRawEvent; + const ev = { id: "one" } as TaggedNostrEvent; const c = new FlatNoteStore(); c.add(ev); c.add(ev); expect(c.getSnapshotData()).toEqual([ev]); }); test("clears", () => { - const ev = { id: "one" } as TaggedRawEvent; + const ev = { id: "one" } as TaggedNostrEvent; const c = new FlatNoteStore(); c.add(ev); expect(c.getSnapshotData()).toEqual([ev]); @@ -28,22 +28,22 @@ describe("NoteStore", () => { }); describe("replacable", () => { test("one event", () => { - const ev = { id: "test", created_at: 69 } as TaggedRawEvent; + const ev = { id: "test", created_at: 69 } as TaggedNostrEvent; const c = new ReplaceableNoteStore(); c.add(ev); expect(c.getSnapshotData()).toEqual(ev); }); test("dont replace with older", () => { - const ev = { id: "test", created_at: 69 } as TaggedRawEvent; - const evOlder = { id: "test2", created_at: 68 } as TaggedRawEvent; + const ev = { id: "test", created_at: 69 } as TaggedNostrEvent; + const evOlder = { id: "test2", created_at: 68 } as TaggedNostrEvent; const c = new ReplaceableNoteStore(); c.add(ev); c.add(evOlder); expect(c.getSnapshotData()).toEqual(ev); }); test("replace with newer", () => { - const ev = { id: "test", created_at: 69 } as TaggedRawEvent; - const evNewer = { id: "test2", created_at: 70 } as TaggedRawEvent; + const ev = { id: "test", created_at: 69 } as TaggedNostrEvent; + const evNewer = { id: "test2", created_at: 70 } as TaggedNostrEvent; const c = new ReplaceableNoteStore(); c.add(ev); c.add(evNewer);