feat: NIP-24
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Kieran 2023-08-17 19:54:14 +01:00
parent 8500dee24f
commit f6a46e3523
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
51 changed files with 792 additions and 319 deletions

View File

@ -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<UnwrappedGift> {
constructor() {
super("GiftWrapCache", db.gifts);
}
key(of: UnwrappedGift): string {
return of.id;
}
override async preload(): Promise<void> {
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<UnwrappedGift> {
return [...this.cache.values()];
}
async onEvent(evs: Array<TaggedNostrEvent>, 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);
}
}

View File

@ -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<string>) {
const preloads = [
@ -17,6 +19,7 @@ export async function preload(follows?: Array<string>) {
InteractionCache.preload(),
UserRelays.preload(follows),
RelayMetrics.preload(),
GiftsCache.preload(),
];
await Promise.all(preloads);
}

View File

@ -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<Array<string>>; // 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<NostrEvent>;
eventInteraction!: Table<EventInteraction>;
payments!: Table<Payment>;
gifts!: Table<UnwrappedGift>;
constructor() {
super(NAME);

View File

@ -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");

View File

@ -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) => {

View File

@ -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 || "<ERROR>");
if (!isMe) {
setLastReadIn(ev.pubkey);
setLastReadIn(msg.id);
}
}
}
function sender() {
if (props.chat.type !== ChatType.DirectMessage && !isMe) {
return <ProfileImage pubkey={ev.pubkey} />;
return <ProfileImage pubkey={msg.from} />;
}
}
useEffect(() => {
if (!decrypted && inView && needsDecryption) {
if (!decrypted && inView && msg.needsDecryption) {
setDecrypted(true);
decrypt().catch(console.error);
}
}, [inView, ev]);
}, [inView, msg]);
return (
<div className={isMe ? "dm me" : "dm other"} ref={ref}>
@ -60,7 +58,7 @@ export default function DM(props: DMProps) {
<Text content={content} tags={[]} creator={otherPubkey} />
</div>
<div>
<NoteTime from={ev.created_at * 1000} fallback={formatMessage(messages.JustNow)} />
<NoteTime from={msg.created_at * 1000} fallback={formatMessage(messages.JustNow)} />
</div>
</div>
);

View File

@ -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;
}

View File

@ -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 <NoteToSelf className="f-grow mb-10" pubkey={p.id} />;
}
if (p.type === "pubkey") {
return <ProfileImage pubkey={p.id} className="f-grow mb10" />;
}
if (p?.profile) {
return <ProfileImage pubkey={p.id} className="f-grow mb10" profile={p.profile} />;
}
return <ProfileImage pubkey={p.id} className="f-grow mb10" overrideUsername={p.id} />;
}
function sender() {
if (id === pubKey) {
return <NoteToSelf className="f-grow mb-10" pubkey={id} />;
if (chat.participants.length === 1) {
return participant(chat.participants[0]);
} else {
return (
<div className="flex pfp-overlap mb10">
{chat.participants.map(v => (
<ProfileImage pubkey={v.id} showUsername={false} />
))}
{chat.title ?? <FormattedMessage defaultMessage="Group Chat" />}
</div>
);
}
if (chat?.type === ChatType.DirectMessage) {
return <ProfileImage pubkey={id} className="f-grow mb10" />;
}
if (chat?.profile) {
return <ProfileImage pubkey={id} className="f-grow mb10" profile={chat.profile} />;
}
return <ProfileImage pubkey={id ?? ""} className="f-grow mb10" overrideUsername={chat?.id} />;
}
return (
@ -55,7 +70,7 @@ function DmChatSelected({ chat }: { chat: Chat }) {
return (
<>
{sortedDms.map(a => (
<DM data={a as TaggedRawEvent} key={a.id} chat={chat} />
<DM data={a} key={a.id} chat={chat} />
))}
</>
);

View File

@ -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();

View File

@ -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) {

View File

@ -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 (
<Note
data={preview as TaggedRawEvent}
data={preview as TaggedNostrEvent}
related={[]}
options={{
showFooter: false,

View File

@ -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, Lists, ParsedZap } from "@snort/system";
import { TaggedNostrEvent, HexKey, u256, encodeTLV, NostrPrefix, Lists, ParsedZap } from "@snort/system";
import { LNURL } from "@snort/shared";
import { useUserProfile } from "@snort/system-react";
@ -56,13 +56,13 @@ export interface Translation {
}
export interface NoteFooterProps {
reposts: TaggedRawEvent[];
reposts: TaggedNostrEvent[];
zaps: ParsedZap[];
positive: TaggedRawEvent[];
negative: TaggedRawEvent[];
positive: TaggedNostrEvent[];
negative: TaggedNostrEvent[];
showReactions: boolean;
setShowReactions(b: boolean): void;
ev: TaggedRawEvent;
ev: TaggedNostrEvent;
onTranslated?: (content: Translation) => void;
}

View File

@ -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);
}

View File

@ -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<ParsedZap>;
}

View File

@ -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[];
}

View File

@ -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<u256, Array<TaggedRawEvent>>;
onNavigate: (e: TaggedRawEvent) => void;
notes: readonly TaggedNostrEvent[];
related: readonly TaggedNostrEvent[];
chains: Map<u256, Array<TaggedNostrEvent>>;
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<SubthreadProps, "notes"> {
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 (
<ThreadNote
@ -187,7 +187,7 @@ const TierThree = ({ active, isLastSubthread, notes, related, chains, onNavigate
/>
)}
{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<u256, Array<TaggedRawEvent>>();
const chains = new Map<u256, Array<TaggedNostrEvent>>();
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<u256, Array<TaggedRawEvent>>): Array<TaggedRawEvent> {
function getReplies(from: u256, chains?: Map<u256, Array<TaggedNostrEvent>>): Array<TaggedNostrEvent> {
if (!from || !chains) {
return [];
}

View File

@ -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 <ProfilePreview actions={<></>} pubkey={e.pubkey} className="card" />;

View File

@ -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() {
<FormattedMessage defaultMessage="Trending Notes" />
</h3>
{posts.map(e => (
<Note key={e.id} data={e as TaggedRawEvent} related={[]} depth={0} />
<Note key={e.id} data={e as TaggedNostrEvent} related={[]} depth={0} />
))}
</>
);

View File

@ -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())];

View File

@ -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);

View File

@ -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;
}

View File

@ -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<RelayList
return b;
}, [pubkeys]);
function mapFromRelays(notes: Array<TaggedRawEvent>): Array<RelayList> {
function mapFromRelays(notes: Array<TaggedNostrEvent>): Array<RelayList> {
return notes.map(ev => {
return {
pubkey: ev.pubkey,
@ -51,7 +51,7 @@ export default function useRelaysFeedFollows(pubkeys: HexKey[]): Array<RelayList
}
// instead of discarding the follow list we should also use it for follow graph
function mapFromContactList(notes: Array<TaggedRawEvent>): Array<RelayList> {
function mapFromContactList(notes: Array<TaggedNostrEvent>): Array<RelayList> {
return notes.map(ev => {
if (ev.content !== "" && ev.content !== "{}" && ev.content.startsWith("{") && ev.content.endsWith("}")) {
try {

View File

@ -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<NotificationRequest | null> {
export async function makeNotification(ev: TaggedNostrEvent): Promise<NotificationRequest | null> {
switch (ev.kind) {
case EventKind.TextNote: {
if (ev.tags.some(tagFilterOfTextRepost(ev))) {
@ -40,7 +40,7 @@ export async function makeNotification(ev: TaggedRawEvent): Promise<Notification
return null;
}
function replaceTagsWithUser(ev: TaggedRawEvent, users: MetadataCache[]) {
function replaceTagsWithUser(ev: TaggedNostrEvent, users: MetadataCache[]) {
return ev.content
.split(MentionRegex)
.map(match => {

View File

@ -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<HTMLDivElement>, 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 <ProfileImage pubkey={p.id} className="f-grow" link="" />;
} else {
return <ProfileImage pubkey={""} overrideUsername={p.id} className="f-grow" link="" />;
}
} else {
return (
<div className="flex f-grow pfp-overlap">
{chat.participants.map(v => (
<ProfileImage pubkey={v.id} link="" showUsername={false} />
))}
<div className="f-grow">{chat.title}</div>
</div>
);
}
}
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 (
<div className="flex mb10" key={chat.id} onClick={e => openChat(e, chat.type, chat.id)}>
{chat.type === ChatType.DirectMessage ? (
<ProfileImage pubkey={chat.id} className="f-grow" link="" />
) : (
<ProfileImage pubkey={chat.id} overrideUsername={chat.id} className="f-grow" link="" />
)}
{conversationIdent(chat)}
<div className="nowrap">
<small>
<NoteTime from={chat.lastMessage * 1000} fallback={formatMessage({ defaultMessage: "Just now" })} />
@ -97,22 +107,31 @@ export default function MessagesPage() {
</div>
{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)}
</div>
)}
{chat && <DmWindow id={chat} />}
{pageWidth >= ThreeCol && chat && (
<div>
<ProfileDmActions pubkey={chat} />
<ProfileDmActions id={chat} />
</div>
)}
</div>
);
}
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();

View File

@ -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<u256>, kind?: EventKind) {
export function getAllReactions(notes: readonly TaggedNostrEvent[] | undefined, ids: Array<u256>, 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<HexKey> }, ev) => {
({ list, seen }: { list: TaggedNostrEvent[]; seen: Set<HexKey> }, 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<T extends { id: string }>(events: Array<T>) {
@ -228,8 +228,8 @@ export function dedupeById<T extends { id: string }>(events: Array<T>) {
* @param events List of all notes to filter from
* @returns
*/
export function getLatestByPubkey(events: TaggedRawEvent[]): Map<HexKey, TaggedRawEvent> {
const deduped = events.reduce((results: Map<HexKey, TaggedRawEvent>, ev) => {
export function getLatestByPubkey(events: TaggedNostrEvent[]): Map<HexKey, TaggedNostrEvent> {
const deduped = events.reduce((results: Map<HexKey, TaggedNostrEvent>, 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<HexKey, TaggedR
}
}
return results;
}, new Map<HexKey, TaggedRawEvent>());
}, new Map<HexKey, TaggedNostrEvent>());
return deduped;
}
@ -274,7 +274,7 @@ export function randomSample<T>(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);
}

View File

@ -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<string>;
zapForward: string;
@ -47,7 +47,7 @@ const NoteCreatorSlice = createSlice({
setPreview: (state, action: PayloadAction<NostrEvent | undefined>) => {
state.preview = action.payload;
},
setReplyTo: (state, action: PayloadAction<TaggedRawEvent | undefined>) => {
setReplyTo: (state, action: PayloadAction<TaggedNostrEvent | undefined>) => {
state.replyTo = action.payload;
},
setShowAdvanced: (state, action: PayloadAction<boolean>) => {

View File

@ -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<T>((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);
},

View File

@ -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<Array<string>>;
needsDecryption: boolean;
content: string;
decrypt: (pub: EventPublisher) => Promise<string>;
}
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<NostrEvent>;
profile?: UserMetadata;
createMessage(msg: string, pub: EventPublisher): Promise<NostrEvent>;
sendMessage(ev: NostrEvent, system: SystemInterface): void | Promise<void>;
participants: Array<ChatParticipant>;
messages: Array<ChatMessage>;
createMessage(msg: string, pub: EventPublisher): Promise<Array<NostrEvent>>;
sendMessage(ev: Array<NostrEvent>, system: SystemInterface): void | Promise<void>;
}
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)));
}

View File

@ -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<Array<Chat>> 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<UnwrappedGift>) {
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);
}
}

View File

View File

@ -57,19 +57,41 @@ export class Nip29ChatSystem extends ExternalStore<Array<Chat>> 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;
});

View File

@ -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<Array<Chat>> 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<NostrEvent>) {
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;
}

View File

@ -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);

View File

@ -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 (

View File

@ -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<NostrEvent | undefined>;
@ -62,7 +62,7 @@ export class Connection extends ExternalStore<ConnectionStateSnapshot> {
ReconnectTimer?: ReturnType<typeof setTimeout>;
EventsCallback: Map<u256, (msg: boolean[]) => 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;

View File

@ -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);
}
}

View File

@ -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

View File

@ -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<void>;
getPubKey(): Promise<string> | string;
nip4Encrypt(content: string, key: string): Promise<string>;
nip4Decrypt(content: string, otherKey: string): Promise<string>;
sign(ev: NostrEvent): Promise<NostrEvent>;
}
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<void> {
return Promise.resolve();
}
getPubKey(): string {
return this.#publicKey;
}
async nip4Encrypt(content: string, key: string): Promise<string> {
return await EventExt.encryptDm(content, this.#privateKey, key);
}
async nip4Decrypt(content: string, otherKey: string): Promise<string> {
return await EventExt.decryptDm(content, this.#privateKey, otherKey);
}
sign(ev: NostrEvent): Promise<NostrEvent> {
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;
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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<string>("nip04_decrypt", [otherKey, content]);
}
nip44Encrypt(content: string, key: string): Promise<string> {
throw new Error("Method not implemented.");
}
nip44Decrypt(content: string, otherKey: string): Promise<string> {
throw new Error("Method not implemented.");
}
async sign(ev: NostrEvent) {
const evStr = await this.#rpc<string>("sign_event", [JSON.stringify(ev)]);
return JSON.parse(evStr);

View File

@ -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<WorkQueueItem> = [];
processWorkQueue(Nip7Queue);
@ -51,6 +50,14 @@ export class Nip7Signer implements EventSigner {
);
}
async nip44Encrypt(content: string, key: string): Promise<string> {
throw new Error("Method not implemented.");
}
async nip44Decrypt(content: string, otherKey: string): Promise<string> {
throw new Error("Method not implemented.");
}
async sign(ev: NostrEvent): Promise<NostrEvent> {
if (!window.nostr) {
throw new Error("Cannot use NIP-07 signer, not found!");

View File

@ -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> | Uint8Array;
encryptData(plaintext: string, sharedSecet: Uint8Array): Promise<string> | string;
decryptData(cyphertext: string, sharedSecet: Uint8Array): Promise<string> | string;
encryptData(plaintext: string, sharedSecet: Uint8Array): Promise<MessageEncryptorPayload> | MessageEncryptorPayload;
decryptData(payload: MessageEncryptorPayload, sharedSecet: Uint8Array): Promise<string> | string;
}

View File

@ -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<TLVEntry>) {
const enc = new TextEncoder();
const buffers: Array<number> = [];
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);

View File

@ -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<SystemSnapshot> implements System
}
}
OnEvent(sub: string, ev: TaggedRawEvent) {
OnEvent(sub: string, ev: TaggedNostrEvent) {
for (const [, v] of this.Queries) {
v.onEvent(sub, ev);
}

View File

@ -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<NostrEvent, "sig">;

View File

@ -1,12 +1,12 @@
import { appendDedupe } from "@snort/shared";
import { TaggedRawEvent, u256 } from ".";
import { TaggedNostrEvent, u256 } from ".";
import { findTag } from "./utils";
export interface StoreSnapshot<TSnapshot> {
data: TSnapshot | undefined;
clear: () => void;
loading: () => boolean;
add: (ev: Readonly<TaggedRawEvent> | Readonly<Array<TaggedRawEvent>>) => void;
add: (ev: Readonly<TaggedNostrEvent> | Readonly<Array<TaggedNostrEvent>>) => void;
}
export const EmptySnapshot = {
@ -20,10 +20,10 @@ export const EmptySnapshot = {
},
} as StoreSnapshot<FlatNoteStore>;
export type NoteStoreSnapshotData = Readonly<Array<TaggedRawEvent>> | Readonly<TaggedRawEvent>;
export type NoteStoreSnapshotData = Readonly<Array<TaggedNostrEvent>> | Readonly<TaggedNostrEvent>;
export type NoteStoreHook = () => void;
export type NoteStoreHookRelease = () => void;
export type OnEventCallback = (e: Readonly<Array<TaggedRawEvent>>) => void;
export type OnEventCallback = (e: Readonly<Array<TaggedNostrEvent>>) => 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<TaggedRawEvent> | Readonly<Array<TaggedRawEvent>>): void;
abstract add(ev: Readonly<TaggedNostrEvent> | Readonly<Array<TaggedNostrEvent>>): void;
abstract clear(): void;
// react hooks
@ -74,7 +74,7 @@ export abstract class HookedNoteStore<TSnapshot extends NoteStoreSnapshotData> i
this.onChange([]);
}
abstract add(ev: Readonly<TaggedRawEvent> | Readonly<Array<TaggedRawEvent>>): void;
abstract add(ev: Readonly<TaggedNostrEvent> | Readonly<Array<TaggedNostrEvent>>): void;
abstract clear(): void;
hook(cb: NoteStoreHook): NoteStoreHookRelease {
@ -106,7 +106,7 @@ export abstract class HookedNoteStore<TSnapshot extends NoteStoreSnapshotData> i
protected abstract takeSnapshot(): TSnapshot | undefined;
protected onChange(changes: Readonly<Array<TaggedRawEvent>>): void {
protected onChange(changes: Readonly<Array<TaggedNostrEvent>>): void {
this.#needsSnapshot = true;
if (!this.#nextNotifyTimer) {
this.#nextNotifyTimer = setTimeout(() => {
@ -137,13 +137,13 @@ export abstract class HookedNoteStore<TSnapshot extends NoteStoreSnapshotData> i
/**
* A simple flat container of events with no duplicates
*/
export class FlatNoteStore extends HookedNoteStore<Readonly<Array<TaggedRawEvent>>> {
#events: Array<TaggedRawEvent> = [];
export class FlatNoteStore extends HookedNoteStore<Readonly<Array<TaggedNostrEvent>>> {
#events: Array<TaggedNostrEvent> = [];
#ids: Set<u256> = new Set();
add(ev: TaggedRawEvent | Array<TaggedRawEvent>) {
add(ev: TaggedNostrEvent | Array<TaggedNostrEvent>) {
ev = Array.isArray(ev) ? ev : [ev];
const changes: Array<TaggedRawEvent> = [];
const changes: Array<TaggedNostrEvent> = [];
ev.forEach(a => {
if (!this.#ids.has(a.id)) {
this.#events.push(a);
@ -176,18 +176,18 @@ export class FlatNoteStore extends HookedNoteStore<Readonly<Array<TaggedRawEvent
/**
* A note store that holds a single replaceable event for a given user defined key generator function
*/
export class KeyedReplaceableNoteStore extends HookedNoteStore<Readonly<Array<TaggedRawEvent>>> {
#keyFn: (ev: TaggedRawEvent) => string;
#events: Map<string, TaggedRawEvent> = new Map();
export class KeyedReplaceableNoteStore extends HookedNoteStore<Readonly<Array<TaggedNostrEvent>>> {
#keyFn: (ev: TaggedNostrEvent) => string;
#events: Map<string, TaggedNostrEvent> = new Map();
constructor(fn: (ev: TaggedRawEvent) => string) {
constructor(fn: (ev: TaggedNostrEvent) => string) {
super();
this.#keyFn = fn;
}
add(ev: TaggedRawEvent | Array<TaggedRawEvent>) {
add(ev: TaggedNostrEvent | Array<TaggedNostrEvent>) {
ev = Array.isArray(ev) ? ev : [ev];
const changes: Array<TaggedRawEvent> = [];
const changes: Array<TaggedNostrEvent> = [];
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<Readonly<Array<Ta
/**
* A note store that holds a single replaceable event
*/
export class ReplaceableNoteStore extends HookedNoteStore<Readonly<TaggedRawEvent>> {
#event?: TaggedRawEvent;
export class ReplaceableNoteStore extends HookedNoteStore<Readonly<TaggedNostrEvent>> {
#event?: TaggedNostrEvent;
add(ev: TaggedRawEvent | Array<TaggedRawEvent>) {
add(ev: TaggedNostrEvent | Array<TaggedNostrEvent>) {
ev = Array.isArray(ev) ? ev : [ev];
const changes: Array<TaggedRawEvent> = [];
const changes: Array<TaggedNostrEvent> = [];
ev.forEach(a => {
const existingCreated = this.#event?.created_at ?? 0;
if (a.created_at > existingCreated) {

View File

@ -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<TaggedRawEvent>) {
async onProfileEvent(e: Readonly<TaggedNostrEvent>) {
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<Readonly<Array<TaggedRawEvent>>>(resolve => {
const results = await new Promise<Readonly<Array<TaggedNostrEvent>>>(resolve => {
let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
const release = feed.hook(() => {
if (!feed.loading) {

View File

@ -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);

View File

@ -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<void>;
getPubKey(): Promise<string> | string;
nip4Encrypt(content: string, key: string): Promise<string>;
nip4Decrypt(content: string, otherKey: string): Promise<string>;
nip44Encrypt(content: string, key: string): Promise<string>;
nip44Decrypt(content: string, otherKey: string): Promise<string>;
sign(ev: NostrEvent): Promise<NostrEvent>;
}
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<void> {
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<NostrEvent> {
EventExt.sign(ev, this.#privateKey);
return Promise.resolve(ev);
}
}

View File

@ -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);