diff --git a/package.json b/package.json index 6abd624f..b2f96b44 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@types/react-dom": "^18.0.10", "@types/webscopeio__react-textarea-autocomplete": "^4.7.2", "@webscopeio/react-textarea-autocomplete": "^4.9.2", + "@types/uuid": "^9.0.0", "bech32": "^2.0.0", "dexie": "^3.2.2", "dexie-react-hooks": "^1.1.1", diff --git a/src/Const.js b/src/Const.ts similarity index 90% rename from src/Const.js rename to src/Const.ts index 8139883a..9993be47 100644 --- a/src/Const.js +++ b/src/Const.ts @@ -1,3 +1,4 @@ +import { RelaySettings } from "./nostr/Connection"; /** * Websocket re-connect timeout @@ -12,11 +13,11 @@ export const ProfileCacheExpire = (1_000 * 60 * 5); /** * Default bootstrap relays */ -export const DefaultRelays = { - "wss://relay.snort.social": { read: true, write: true }, - "wss://relay.damus.io": { read: true, write: true }, - "wss://nostr-pub.wellorder.net": { read: true, write: true } -}; +export const DefaultRelays = new Map([ + ["wss://relay.snort.social", { read: true, write: true }], + ["wss://relay.damus.io", { read: true, write: true }], + ["wss://nostr-pub.wellorder.net", { read: true, write: true }], +]); /** * List of recommended follows for new users diff --git a/src/Util.js b/src/Util.ts similarity index 80% rename from src/Util.js rename to src/Util.ts index 471d6713..30441a87 100644 --- a/src/Util.js +++ b/src/Util.ts @@ -1,12 +1,16 @@ import * as secp from "@noble/secp256k1"; import { bech32 } from "bech32"; +import { HexKey, u256 } from "./nostr"; export async function openFile() { return new Promise((resolve, reject) => { let elm = document.createElement("input"); elm.type = "file"; - elm.onchange = (e) => { - resolve(e.target.files[0]); + elm.onchange = (e: Event) => { + let elm = e.target as HTMLInputElement; + if (elm.files) { + resolve(elm.files[0]); + } }; elm.click(); }); @@ -15,9 +19,9 @@ export async function openFile() { /** * Parse bech32 ids * https://github.com/nostr-protocol/nips/blob/master/19.md - * @param {string} id bech32 id + * @param id bech32 id */ -export function parseId(id) { +export function parseId(id: string) { const hrp = ["note", "npub", "nsec"]; try { if (hrp.some(a => id.startsWith(a))) { @@ -27,7 +31,7 @@ export function parseId(id) { return id; } -export function bech32ToHex(str) { +export function bech32ToHex(str: string) { let nKey = bech32.decode(str); let buff = bech32.fromWords(nKey.words); return secp.utils.bytesToHex(Uint8Array.from(buff)); @@ -35,10 +39,10 @@ export function bech32ToHex(str) { /** * Decode bech32 to string UTF-8 - * @param {string} str bech32 encoded string + * @param str bech32 encoded string * @returns */ -export function bech32ToText(str) { +export function bech32ToText(str: string) { let decoded = bech32.decode(str, 1000); let buf = bech32.fromWords(decoded.words); return new TextDecoder().decode(Uint8Array.from(buf)); @@ -46,10 +50,10 @@ export function bech32ToText(str) { /** * Convert hex note id to bech32 link url - * @param {string} hex + * @param hex * @returns */ -export function eventLink(hex) { +export function eventLink(hex: u256) { return `/e/${hexToBech32("note", hex)}`; } @@ -57,11 +61,11 @@ export function eventLink(hex) { * Convert hex to bech32 * @param {string} hex */ -export function hexToBech32(hrp, hex) { +export function hexToBech32(hrp: string, hex: string) { if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 != 0) { return ""; } - + try { let buf = secp.utils.hexToBytes(hex); return bech32.encode(hrp, bech32.toWords(buf)); @@ -76,7 +80,7 @@ export function hexToBech32(hrp, hex) { * @param {string} hex * @returns */ -export function profileLink(hex) { +export function profileLink(hex: HexKey) { return `/p/${hexToBech32("npub", hex)}`; } @@ -93,7 +97,7 @@ export const Reaction = { * @param {string} content * @returns */ -export function normalizeReaction(content) { +export function normalizeReaction(content: string) { switch (content) { case "": return Reaction.Positive; case "🤙": return Reaction.Positive; @@ -109,10 +113,10 @@ export function normalizeReaction(content) { /** * Converts LNURL service to LN Address - * @param {string} lnurl + * @param lnurl * @returns */ -export function extractLnAddress(lnurl) { +export function extractLnAddress(lnurl: string) { // some clients incorrectly set this to LNURL service, patch this if (lnurl.toLowerCase().startsWith("lnurl")) { let url = bech32ToText(lnurl); diff --git a/src/element/DM.tsx b/src/element/DM.tsx index c38e8266..0bea6f95 100644 --- a/src/element/DM.tsx +++ b/src/element/DM.tsx @@ -24,9 +24,9 @@ export default function DM(props: DMProps) { const { ref, inView, entry } = useInView(); async function decrypt() { - let e = Event.FromObject(props.data); + let e = new Event(props.data); let decrypted = await publisher.decryptDm(e); - setContent(decrypted); + setContent(decrypted || ""); } useEffect(() => { diff --git a/src/element/Note.js b/src/element/Note.js index fc4a5af6..080e88f7 100644 --- a/src/element/Note.js +++ b/src/element/Note.js @@ -9,6 +9,7 @@ import Text from "./Text"; import { eventLink, hexToBech32 } from "../Util"; import NoteFooter from "./NoteFooter"; import NoteTime from "./NoteTime"; +import EventKind from "../nostr/EventKind"; export default function Note(props) { const navigate = useNavigate(); @@ -17,7 +18,7 @@ export default function Note(props) { const { data, isThread, reactions, deletion, hightlight } = props const users = useSelector(s => s.users?.users); - const ev = dataEvent ?? Event.FromObject(data); + const ev = dataEvent ?? new Event(data); const options = { showHeader: true, @@ -59,7 +60,7 @@ export default function Note(props) { ) } - if (!ev.IsContent()) { + if (ev.Kind !== EventKind.TextNote) { return ( <>

Unknown event kind: {ev.Kind}

diff --git a/src/element/NoteReaction.js b/src/element/NoteReaction.js index 6f4e8215..3a5c1901 100644 --- a/src/element/NoteReaction.js +++ b/src/element/NoteReaction.js @@ -9,7 +9,7 @@ import { useMemo } from "react"; import NoteTime from "./NoteTime"; export default function NoteReaction(props) { - const ev = props["data-ev"] || Event.FromObject(props.data); + const ev = props["data-ev"] || new Event(props.data); const refEvent = useMemo(() => { if (ev) { diff --git a/src/element/Thread.js b/src/element/Thread.js index 313a710b..af4c67b2 100644 --- a/src/element/Thread.js +++ b/src/element/Thread.js @@ -10,7 +10,7 @@ export default function Thread(props) { const thisEvent = props.this; /** @type {Array} */ - const notes = props.notes?.map(a => Event.FromObject(a)); + const notes = props.notes?.map(a => new Event(a)); // root note has no thread info const root = useMemo(() => notes.find(a => a.Thread === null), [notes]); diff --git a/src/element/Timeline.js b/src/element/Timeline.js index c0f4003c..b4b6d3ab 100644 --- a/src/element/Timeline.js +++ b/src/element/Timeline.js @@ -25,7 +25,7 @@ export default function Timeline({ global, pubkeys }) { } case EventKind.Reaction: case EventKind.Repost: { - return + return } } } diff --git a/src/feed/EventPublisher.js b/src/feed/EventPublisher.js deleted file mode 100644 index 181d9885..00000000 --- a/src/feed/EventPublisher.js +++ /dev/null @@ -1,233 +0,0 @@ -import { useSelector } from "react-redux"; - -import { System } from ".."; -import Event from "../nostr/Event"; -import EventKind from "../nostr/EventKind"; -import Tag from "../nostr/Tag"; -import { bech32ToHex } from "../Util" - -export default function useEventPublisher() { - const pubKey = useSelector(s => s.login.publicKey); - const privKey = useSelector(s => s.login.privateKey); - const follows = useSelector(s => s.login.follows); - const relays = useSelector(s => s.login.relays); - const hasNip07 = 'nostr' in window; - - /** - * - * @param {Event} ev - * @param {*} privKey - * @returns - */ - async function signEvent(ev) { - if (hasNip07 && !privKey) { - ev.Id = await ev.CreateId(); - let tmpEv = await barierNip07(() => window.nostr.signEvent(ev.ToObject())); - return Event.FromObject(tmpEv); - } else { - await ev.Sign(privKey); - } - return ev; - } - - - function processMentions(ev, msg) { - const replaceNpub = (match) => { - const npub = match.slice(1); - try { - const hex = bech32ToHex(npub); - const idx = ev.Tags.length; - ev.Tags.push(new Tag(["p", hex], idx)); - return `#[${idx}]` - } catch (error) { - return match - } - } - let content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub) - ev.Content = content; - } - - return { - broadcast: (ev) => { - console.debug("Sending event: ", ev); - System.BroadcastEvent(ev); - }, - metadata: async (obj) => { - let ev = Event.ForPubKey(pubKey); - ev.Kind = EventKind.SetMetadata; - ev.Content = JSON.stringify(obj); - return await signEvent(ev, privKey); - }, - note: async (msg) => { - if (typeof msg !== "string") { - throw "Must be text!"; - } - let ev = Event.ForPubKey(pubKey); - ev.Kind = EventKind.TextNote; - processMentions(ev, msg); - return await signEvent(ev); - }, - /** - * Reply to a note - * @param {Event} replyTo - * @param {String} msg - * @returns - */ - reply: async (replyTo, msg) => { - if (typeof msg !== "string") { - throw "Must be text!"; - } - let ev = Event.ForPubKey(pubKey); - ev.Kind = EventKind.TextNote; - - let thread = replyTo.Thread; - if (thread) { - if (thread.Root) { - ev.Tags.push(new Tag(["e", thread.Root.Event, "", "root"], ev.Tags.length)); - } else { - ev.Tags.push(new Tag(["e", thread.ReplyTo.Event, "", "root"], ev.Tags.length)); - } - ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length)); - ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length)); - for (let pk of thread.PubKeys) { - if (pk === pubKey) { - continue; // dont tag self in replies - } - ev.Tags.push(new Tag(["p", pk], ev.Tags.length)); - } - } else { - ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0)); - ev.Tags.push(new Tag(["p", replyTo.PubKey], 1)); - } - processMentions(ev, msg); - return await signEvent(ev); - }, - react: async (evRef, content = "+") => { - let ev = Event.ForPubKey(pubKey); - ev.Kind = EventKind.Reaction; - ev.Content = content; - ev.Tags.push(new Tag(["e", evRef.Id], 0)); - ev.Tags.push(new Tag(["p", evRef.PubKey], 1)); - return await signEvent(ev); - }, - saveRelays: async () => { - let ev = Event.ForPubKey(pubKey); - ev.Kind = EventKind.ContactList; - ev.Content = JSON.stringify(relays); - for (let pk of follows) { - ev.Tags.push(new Tag(["p", pk])); - } - - return await signEvent(ev); - }, - addFollow: async (pkAdd) => { - let ev = Event.ForPubKey(pubKey); - ev.Kind = EventKind.ContactList; - ev.Content = JSON.stringify(relays); - let temp = new Set(follows); - if (Array.isArray(pkAdd)) { - pkAdd.forEach(a => temp.add(a)); - } else { - temp.add(pkAdd); - } - for (let pk of temp) { - ev.Tags.push(new Tag(["p", pk])); - } - - return await signEvent(ev); - }, - removeFollow: async (pkRemove) => { - let ev = Event.ForPubKey(pubKey); - ev.Kind = EventKind.ContactList; - ev.Content = JSON.stringify(relays); - for (let pk of follows) { - if (pk === pkRemove) { - continue; - } - ev.Tags.push(new Tag(["p", pk])); - } - - return await signEvent(ev); - }, - delete: async (id) => { - let ev = Event.ForPubKey(pubKey); - ev.Kind = EventKind.Deletion; - ev.Content = ""; - ev.Tags.push(new Tag(["e", id])); - return await signEvent(ev); - }, - /** - * Respot a note - * @param {Event} note - * @returns - */ - repost: async (note) => { - if (typeof note.Id !== "string") { - throw "Must be parsed note in Event class"; - } - let ev = Event.ForPubKey(pubKey); - ev.Kind = EventKind.Repost; - ev.Content = JSON.stringify(note.Original); - ev.Tags.push(new Tag(["e", note.Id])); - ev.Tags.push(new Tag(["p", note.PubKey])); - return await signEvent(ev); - }, - decryptDm: async (note) => { - if (note.PubKey !== pubKey && !note.Tags.some(a => a.PubKey === pubKey)) { - return ""; - } - try { - let otherPubKey = note.PubKey === pubKey ? note.Tags.filter(a => a.Key === "p")[0].PubKey : note.PubKey; - if (hasNip07 && !privKey) { - return await barierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.Content)); - } else if(privKey) { - await note.DecryptDm(privKey, otherPubKey); - return note.Content; - } - } catch (e) { - console.error("Decyrption failed", e); - return ""; - } - return "test"; - }, - sendDm: async (content, to) => { - let ev = Event.ForPubKey(pubKey); - ev.Kind = EventKind.DirectMessage; - ev.Content = content; - ev.Tags.push(new Tag(["p", to])); - - try { - if (hasNip07 && !privKey) { - let cx = await barierNip07(() => window.nostr.nip04.encrypt(to, content)); - ev.Content = cx; - return await signEvent(ev); - } else if(privKey) { - await ev.EncryptDmForPubkey(to, privKey); - return await signEvent(ev); - } - } catch (e) { - console.error("Encryption failed", e); - } - } - } -} - -let isNip07Busy = false; - -const delay = (t) => { - return new Promise((resolve, reject) => { - setTimeout(resolve, t); - }); -} - -const barierNip07 = async (then) => { - while (isNip07Busy) { - await delay(10); - } - isNip07Busy = true; - try { - return await then(); - } finally { - isNip07Busy = false; - } -}; \ No newline at end of file diff --git a/src/feed/EventPublisher.ts b/src/feed/EventPublisher.ts new file mode 100644 index 00000000..9109a441 --- /dev/null +++ b/src/feed/EventPublisher.ts @@ -0,0 +1,259 @@ +import { useSelector } from "react-redux"; +import { System } from "../nostr/System"; +import { default as NEvent } from "../nostr/Event"; +import EventKind from "../nostr/EventKind"; +import Tag from "../nostr/Tag"; +import { RootState } from "../state/Store"; +import { HexKey, RawEvent, u256, UserMetadata } from "../nostr"; +import { bech32ToHex } from "../Util" + +declare global { + interface Window { + nostr: { + getPublicKey: () => Promise, + signEvent: (event: RawEvent) => Promise, + getRelays: () => Promise<[[string, { read: boolean, write: boolean }]]>, + nip04: { + encrypt: (pubkey: HexKey, content: string) => Promise, + decrypt: (pubkey: HexKey, content: string) => Promise + } + } + } +} + +export default function useEventPublisher() { + const pubKey = useSelector(s => s.login.publicKey); + const privKey = useSelector(s => s.login.privateKey); + const follows = useSelector(s => s.login.follows); + const relays = useSelector(s => s.login.relays); + const hasNip07 = 'nostr' in window; + + async function signEvent(ev: NEvent): Promise { + if (hasNip07 && !privKey) { + ev.Id = await ev.CreateId(); + let tmpEv = await barierNip07(() => window.nostr.signEvent(ev.ToObject())); + return new NEvent(tmpEv); + } else if (privKey) { + await ev.Sign(privKey); + } else { + console.warn("Count not sign event, no private keys available"); + } + return ev; + } + + function processMentions(ev: NEvent, msg: string) { + const replaceNpub = (match: string) => { + const npub = match.slice(1); + try { + const hex = bech32ToHex(npub); + const idx = ev.Tags.length; + ev.Tags.push(new Tag(["p", hex], idx)); + return `#[${idx}]` + } catch (error) { + return match + } + } + let content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub) + ev.Content = content; + } + + return { + broadcast: (ev: NEvent) => { + console.debug("Sending event: ", ev); + System.BroadcastEvent(ev); + }, + metadata: async (obj: UserMetadata) => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.SetMetadata; + ev.Content = JSON.stringify(obj); + return await signEvent(ev); + } + }, + note: async (msg: string) => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.TextNote; + processMentions(ev, msg); + return await signEvent(ev); + } + }, + /** + * Reply to a note + */ + reply: async (replyTo: NEvent, msg: string) => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.TextNote; + + let thread = replyTo.Thread; + if (thread) { + if (thread.Root || thread.ReplyTo) { + ev.Tags.push(new Tag(["e", thread.Root?.Event ?? thread.ReplyTo?.Event!, "", "root"], ev.Tags.length)); + } + ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length)); + + // dont tag self in replies + if (replyTo.PubKey !== pubKey) { + ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length)); + } + + for (let pk of thread.PubKeys) { + if (pk === pubKey) { + continue; // dont tag self in replies + } + ev.Tags.push(new Tag(["p", pk], ev.Tags.length)); + } + } else { + ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0)); + // dont tag self in replies + if (replyTo.PubKey !== pubKey) { + ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length)); + } + } + processMentions(ev, msg); + return await signEvent(ev); + } + }, + react: async (evRef: NEvent, content = "+") => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.Reaction; + ev.Content = content; + ev.Tags.push(new Tag(["e", evRef.Id], 0)); + ev.Tags.push(new Tag(["p", evRef.PubKey], 1)); + return await signEvent(ev); + } + }, + saveRelays: async () => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.ContactList; + ev.Content = JSON.stringify(relays); + for (let pk of follows) { + ev.Tags.push(new Tag(["p", pk], ev.Tags.length)); + } + + return await signEvent(ev); + } + }, + addFollow: async (pkAdd: HexKey) => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.ContactList; + ev.Content = JSON.stringify(relays); + let temp = new Set(follows); + if (Array.isArray(pkAdd)) { + pkAdd.forEach(a => temp.add(a)); + } else { + temp.add(pkAdd); + } + for (let pk of temp) { + ev.Tags.push(new Tag(["p", pk], ev.Tags.length)); + } + + return await signEvent(ev); + } + }, + removeFollow: async (pkRemove: HexKey) => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.ContactList; + ev.Content = JSON.stringify(relays); + for (let pk of follows) { + if (pk === pkRemove) { + continue; + } + ev.Tags.push(new Tag(["p", pk], ev.Tags.length)); + } + + return await signEvent(ev); + } + }, + /** + * Delete an event (NIP-09) + */ + delete: async (id: u256) => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.Deletion; + ev.Content = ""; + ev.Tags.push(new Tag(["e", id], 0)); + return await signEvent(ev); + } + }, + /** + * Respot a note (NIP-18) + */ + repost: async (note: NEvent) => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.Repost; + ev.Content = JSON.stringify(note.Original); + ev.Tags.push(new Tag(["e", note.Id], 0)); + ev.Tags.push(new Tag(["p", note.PubKey], 1)); + return await signEvent(ev); + } + }, + decryptDm: async (note: NEvent): Promise => { + if (pubKey) { + if (note.PubKey !== pubKey && !note.Tags.some(a => a.PubKey === pubKey)) { + return ""; + } + try { + let otherPubKey = note.PubKey === pubKey ? note.Tags.filter(a => a.Key === "p")[0].PubKey! : note.PubKey; + if (hasNip07 && !privKey) { + return await barierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.Content)); + } else if (privKey) { + await note.DecryptDm(privKey, otherPubKey); + return note.Content; + } + } catch (e) { + console.error("Decyrption failed", e); + return ""; + } + } + }, + sendDm: async (content: string, to: HexKey) => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.DirectMessage; + ev.Content = content; + ev.Tags.push(new Tag(["p", to], 0)); + + try { + if (hasNip07 && !privKey) { + let cx: string = await barierNip07(() => window.nostr.nip04.encrypt(to, content)); + ev.Content = cx; + return await signEvent(ev); + } else if (privKey) { + await ev.EncryptDmForPubkey(to, privKey); + return await signEvent(ev); + } + } catch (e) { + console.error("Encryption failed", e); + } + } + } + } +} + +let isNip07Busy = false; + +const delay = (t: number) => { + return new Promise((resolve, reject) => { + setTimeout(resolve, t); + }); +} + +const barierNip07 = async (then: () => Promise) => { + while (isNip07Busy) { + await delay(10); + } + isNip07Busy = true; + try { + return await then(); + } finally { + isNip07Busy = false; + } +}; \ No newline at end of file diff --git a/src/feed/FollowersFeed.js b/src/feed/FollowersFeed.ts similarity index 65% rename from src/feed/FollowersFeed.js rename to src/feed/FollowersFeed.ts index 5e3f1d74..efd6aa3f 100644 --- a/src/feed/FollowersFeed.js +++ b/src/feed/FollowersFeed.ts @@ -1,14 +1,15 @@ import { useMemo } from "react"; +import { HexKey } from "../nostr"; import EventKind from "../nostr/EventKind"; import { Subscriptions } from "../nostr/Subscriptions"; import useSubscription from "./Subscription"; -export default function useFollowersFeed(pubkey) { +export default function useFollowersFeed(pubkey: HexKey) { const sub = useMemo(() => { let x = new Subscriptions(); x.Id = "followers"; - x.Kinds.add(EventKind.ContactList); - x.PTags.add(pubkey); + x.Kinds = new Set([EventKind.ContactList]); + x.PTags = new Set([pubkey]); return x; }, [pubkey]); diff --git a/src/feed/FollowsFeed.js b/src/feed/FollowsFeed.ts similarity index 65% rename from src/feed/FollowsFeed.js rename to src/feed/FollowsFeed.ts index bccd248c..657f3f0b 100644 --- a/src/feed/FollowsFeed.js +++ b/src/feed/FollowsFeed.ts @@ -1,14 +1,15 @@ import { useMemo } from "react"; +import { HexKey } from "../nostr"; import EventKind from "../nostr/EventKind"; import { Subscriptions } from "../nostr/Subscriptions"; import useSubscription from "./Subscription"; -export default function useFollowsFeed(pubkey) { +export default function useFollowsFeed(pubkey: HexKey) { const sub = useMemo(() => { let x = new Subscriptions(); x.Id = "follows"; - x.Kinds.add(EventKind.ContactList); - x.Authors.add(pubkey); + x.Kinds = new Set([EventKind.ContactList]); + x.Authors = new Set([pubkey]); return x; }, [pubkey]); diff --git a/src/feed/LoginFeed.js b/src/feed/LoginFeed.ts similarity index 66% rename from src/feed/LoginFeed.js rename to src/feed/LoginFeed.ts index 5cadfc40..5ae059c8 100644 --- a/src/feed/LoginFeed.js +++ b/src/feed/LoginFeed.ts @@ -1,8 +1,10 @@ import { useEffect, useMemo } from "react"; import { useDispatch, useSelector } from "react-redux"; +import { HexKey } from "../nostr"; import EventKind from "../nostr/EventKind"; import { Subscriptions } from "../nostr/Subscriptions"; import { addDirectMessage, addNotifications, setFollows, setRelays } from "../state/Login"; +import { RootState } from "../state/Store"; import { setUserData } from "../state/Users"; import { db } from "../db"; import useSubscription from "./Subscription"; @@ -13,7 +15,7 @@ import { mapEventToProfile } from "./UsersFeed"; */ export default function useLoginFeed() { const dispatch = useDispatch(); - const [pubKey, readNotifications] = useSelector(s => [s.login.publicKey, s.login.readNotifications]); + const [pubKey, readNotifications] = useSelector(s => [s.login.publicKey, s.login.readNotifications]); const sub = useMemo(() => { if (!pubKey) { @@ -22,29 +24,29 @@ export default function useLoginFeed() { let sub = new Subscriptions(); sub.Id = `login:${sub.Id}`; - sub.Authors.add(pubKey); - sub.Kinds.add(EventKind.ContactList); - sub.Kinds.add(EventKind.SetMetadata); - sub.Kinds.add(EventKind.DirectMessage); + sub.Authors = new Set([pubKey]); + sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata, EventKind.DirectMessage]); let notifications = new Subscriptions(); - notifications.Kinds.add(EventKind.TextNote); - notifications.Kinds.add(EventKind.DirectMessage); - notifications.PTags.add(pubKey); + notifications.Kinds = new Set([EventKind.TextNote, EventKind.DirectMessage]); + notifications.PTags = new Set([pubKey]); notifications.Limit = 100; sub.AddSubscription(notifications); return sub; }, [pubKey]); - const { notes } = useSubscription(sub, { leaveOpen: true }); + const main = useSubscription(sub, { leaveOpen: true }); useEffect(() => { - let contactList = notes.filter(a => a.kind === EventKind.ContactList); - let notifications = notes.filter(a => a.kind === EventKind.TextNote); - let metadata = notes.filter(a => a.kind === EventKind.SetMetadata) + let contactList = main.notes.filter(a => a.kind === EventKind.ContactList); + let notifications = main.notes.filter(a => a.kind === EventKind.TextNote); + let metadata = main.notes.filter(a => a.kind === EventKind.SetMetadata) + .map(a => mapEventToProfile(a)) + .filter(a => a !== undefined) + .map(a => a!); let profiles = metadata.map(a => mapEventToProfile(a)); - let dms = notes.filter(a => a.kind === EventKind.DirectMessage); + let dms = main.notes.filter(a => a.kind === EventKind.DirectMessage); for (let cl of contactList) { if (cl.content !== "") { @@ -68,5 +70,5 @@ export default function useLoginFeed() { }) db.users.bulkPut(metadata); dispatch(addDirectMessage(dms)); - }, [notes]); + }, [main]); } \ No newline at end of file diff --git a/src/feed/ProfileFeed.js b/src/feed/ProfileFeed.js deleted file mode 100644 index 743ef34d..00000000 --- a/src/feed/ProfileFeed.js +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect } from "react"; -import { useDispatch, useSelector } from "react-redux"; -import { addPubKey } from "../state/Users"; - -export default function useProfile(pubKey) { - const dispatch = useDispatch(); - const user = useSelector(s => s.users.users[pubKey]); - - useEffect(() => { - if (pubKey) { - dispatch(addPubKey(pubKey)); - } - }, [pubKey]); - - return user; -} \ No newline at end of file diff --git a/src/feed/ProfileFeed.ts b/src/feed/ProfileFeed.ts new file mode 100644 index 00000000..20ccdb4d --- /dev/null +++ b/src/feed/ProfileFeed.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { HexKey } from "../nostr"; +import { RootState } from "../state/Store"; +import { addPubKey, MetadataCache } from "../state/Users"; + +export default function useProfile(pubKey: HexKey) { + const dispatch = useDispatch(); + const user = useSelector(s => s.users.users[pubKey]); + + useEffect(() => { + if (pubKey) { + dispatch(addPubKey(pubKey)); + } + }, [pubKey]); + + return user; +} \ No newline at end of file diff --git a/src/feed/RelayState.js b/src/feed/RelayState.js deleted file mode 100644 index 3d82d938..00000000 --- a/src/feed/RelayState.js +++ /dev/null @@ -1,9 +0,0 @@ -import { useSyncExternalStore } from "react"; -import { System } from ".."; - -const noop = () => {}; - -export default function useRelayState(addr) { - let c = System.Sockets[addr]; - return useSyncExternalStore(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noop); -} \ No newline at end of file diff --git a/src/feed/RelayState.ts b/src/feed/RelayState.ts new file mode 100644 index 00000000..b12ca351 --- /dev/null +++ b/src/feed/RelayState.ts @@ -0,0 +1,11 @@ +import { useSyncExternalStore } from "react"; +import { System } from "../nostr/System"; +import { CustomHook } from "../nostr/Connection"; + +const noop = (f: CustomHook) => { return () => { }; }; +const noopState = () => { }; + +export default function useRelayState(addr: string) { + let c = System.Sockets.get(addr); + return useSyncExternalStore(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noopState); +} \ No newline at end of file diff --git a/src/feed/Subscription.js b/src/feed/Subscription.ts similarity index 66% rename from src/feed/Subscription.js rename to src/feed/Subscription.ts index b1bb523a..feeeae16 100644 --- a/src/feed/Subscription.js +++ b/src/feed/Subscription.ts @@ -1,8 +1,17 @@ import { useEffect, useReducer } from "react"; -import { System } from ".."; +import { System } from "../nostr/System"; +import { TaggedRawEvent } from "../nostr"; import { Subscriptions } from "../nostr/Subscriptions"; -function notesReducer(state, ev) { +export type NoteStore = { + notes: Array +}; + +export type UseSubscriptionOptions = { + leaveOpen: boolean +} + +function notesReducer(state: NoteStore, ev: TaggedRawEvent) { if (state.notes.some(a => a.id === ev.id)) { return state; } @@ -21,13 +30,8 @@ function notesReducer(state, ev) { * @param {any} opt * @returns */ -export default function useSubscription(sub, opt) { - const [state, dispatch] = useReducer(notesReducer, { notes: [] }); - - const options = { - leaveOpen: false, - ...opt - }; +export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions) { + const [state, dispatch] = useReducer(notesReducer, { notes: [] }); useEffect(() => { if (sub) { @@ -35,7 +39,7 @@ export default function useSubscription(sub, opt) { dispatch(e); }; - if (!options.leaveOpen) { + if (!(options?.leaveOpen ?? false)) { sub.OnEnd = (c) => { c.RemoveSubscription(sub.Id); if (sub.IsFinished()) { diff --git a/src/feed/ThreadFeed.js b/src/feed/ThreadFeed.ts similarity index 78% rename from src/feed/ThreadFeed.js rename to src/feed/ThreadFeed.ts index 62643453..670c504d 100644 --- a/src/feed/ThreadFeed.js +++ b/src/feed/ThreadFeed.ts @@ -1,19 +1,18 @@ import { useMemo } from "react"; +import { u256 } from "../nostr"; import EventKind from "../nostr/EventKind"; import { Subscriptions } from "../nostr/Subscriptions"; import useSubscription from "./Subscription"; -export default function useThreadFeed(id) { +export default function useThreadFeed(id: u256) { const sub = useMemo(() => { const thisSub = new Subscriptions(); thisSub.Id = `thread:${id.substring(0, 8)}`; - thisSub.Ids.add(id); + thisSub.Ids = new Set([id]); // get replies to this event const subRelated = new Subscriptions(); - subRelated.Kinds.add(EventKind.Reaction); - subRelated.Kinds.add(EventKind.TextNote); - subRelated.Kinds.add(EventKind.Deletion); + subRelated.Kinds = new Set([EventKind.Reaction, EventKind.TextNote, EventKind.Deletion]); subRelated.ETags = thisSub.Ids; thisSub.AddSubscription(subRelated); @@ -28,6 +27,7 @@ export default function useThreadFeed(id) { if (thisNote) { let otherSubs = new Subscriptions(); otherSubs.Id = `thread-related:${id.substring(0, 8)}`; + otherSubs.Ids = new Set(); for (let e of thisNote.tags.filter(a => a[0] === "e")) { otherSubs.Ids.add(e[1]); } @@ -37,15 +37,14 @@ export default function useThreadFeed(id) { } let relatedSubs = new Subscriptions(); - relatedSubs.Kinds.add(EventKind.Reaction); - relatedSubs.Kinds.add(EventKind.TextNote); - relatedSubs.Kinds.add(EventKind.Deletion); + relatedSubs.Kinds = new Set([EventKind.Reaction, EventKind.TextNote, EventKind.Deletion]); relatedSubs.ETags = otherSubs.Ids; otherSubs.AddSubscription(relatedSubs); return otherSubs; } - }, [main.notes]); + return null; + }, [main]); const others = useSubscription(relatedThisSub, { leaveOpen: true }); diff --git a/src/feed/TimelineFeed.js b/src/feed/TimelineFeed.ts similarity index 80% rename from src/feed/TimelineFeed.js rename to src/feed/TimelineFeed.ts index f913cc20..6eeed126 100644 --- a/src/feed/TimelineFeed.js +++ b/src/feed/TimelineFeed.ts @@ -1,9 +1,10 @@ import { useMemo } from "react"; +import { HexKey } from "../nostr"; import EventKind from "../nostr/EventKind"; import { Subscriptions } from "../nostr/Subscriptions"; import useSubscription from "./Subscription"; -export default function useTimelineFeed(pubKeys, global = false) { +export default function useTimelineFeed(pubKeys: HexKey | Array, global: boolean = false) { const subTab = global ? "global" : "follows"; const sub = useMemo(() => { if (!Array.isArray(pubKeys)) { @@ -17,8 +18,7 @@ export default function useTimelineFeed(pubKeys, global = false) { let sub = new Subscriptions(); sub.Id = `timeline:${subTab}`; sub.Authors = new Set(global ? [] : pubKeys); - sub.Kinds.add(EventKind.TextNote); - sub.Kinds.add(EventKind.Repost); + sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]); sub.Limit = 20; return sub; @@ -31,8 +31,7 @@ export default function useTimelineFeed(pubKeys, global = false) { if (main.notes.length > 0) { let sub = new Subscriptions(); sub.Id = `timeline-related:${subTab}`; - sub.Kinds.add(EventKind.Reaction); - sub.Kinds.add(EventKind.Deletion); + sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion]); sub.ETags = new Set(main.notes.map(a => a.id)); return sub; diff --git a/src/feed/UsersFeed.js b/src/feed/UsersFeed.ts similarity index 54% rename from src/feed/UsersFeed.js rename to src/feed/UsersFeed.ts index 68db3f05..1458af6a 100644 --- a/src/feed/UsersFeed.js +++ b/src/feed/UsersFeed.ts @@ -1,21 +1,23 @@ import { useEffect, useMemo } from "react"; import { useDispatch, useSelector } from "react-redux"; import { ProfileCacheExpire } from "../Const"; +import { HexKey, TaggedRawEvent, UserMetadata } from "../nostr"; import EventKind from "../nostr/EventKind"; import { db } from "../db"; import { Subscriptions } from "../nostr/Subscriptions"; -import { setUserData } from "../state/Users"; +import { RootState } from "../state/Store"; +import { MetadataCache, setUserData } from "../state/Users"; import useSubscription from "./Subscription"; export default function useUsersCache() { const dispatch = useDispatch(); - const pKeys = useSelector(s => s.users.pubKeys); - const users = useSelector(s => s.users.users); + const pKeys = useSelector(s => s.users.pubKeys); + const users = useSelector(s => s.users.users); - function isUserCached(id) { + function isUserCached(id: HexKey) { let expire = new Date().getTime() - ProfileCacheExpire; let u = users[id]; - return u && u.loaded > expire; + return u !== undefined && u.loaded > expire; } const sub = useMemo(() => { @@ -27,7 +29,7 @@ export default function useUsersCache() { let sub = new Subscriptions(); sub.Id = `profiles:${sub.Id}`; sub.Authors = new Set(needProfiles.slice(0, 20)); - sub.Kinds.add(EventKind.SetMetadata); + sub.Kinds = new Set([EventKind.SetMetadata]); return sub; }, [pKeys]); @@ -35,23 +37,26 @@ export default function useUsersCache() { const results = useSubscription(sub); useEffect(() => { - const userData = results.notes.map(a => mapEventToProfile(a)); - dispatch(setUserData(userData)); - const profiles = results.notes.map(ev => { - return {...JSON.parse(ev.content), pubkey: ev.pubkey } + let profiles: MetadataCache[] = results.notes + .map(a => mapEventToProfile(a)) + .filter(a => a !== undefined) + .map(a => a!); + dispatch(setUserData(profiles)); + const dbProfiles = results.notes.map(ev => { + return { ...JSON.parse(ev.content), pubkey: ev.pubkey } }); - db.users.bulkPut(profiles); + db.users.bulkPut(dbProfiles); }, [results]); return results; } -export function mapEventToProfile(ev) { +export function mapEventToProfile(ev: TaggedRawEvent): MetadataCache | undefined { try { - let data = JSON.parse(ev.content); + let data: UserMetadata = JSON.parse(ev.content); return { pubkey: ev.pubkey, - fromEvent: ev, + created: ev.created_at, loaded: new Date().getTime(), ...data }; diff --git a/src/index.css b/src/index.css index 252873ec..5ab6b287 100644 --- a/src/index.css +++ b/src/index.css @@ -43,6 +43,7 @@ body { -moz-osx-font-smoothing: grayscale; background-color: var(--bg-color); color: var(--font-color); + font-size: 14px; } code { @@ -213,13 +214,6 @@ span.pill:hover { cursor: pointer; } -@media(max-width: 720px) { - .page { - width: calc(100vw - 20px); - margin: 0 10px; - } -} - div.form-group { display: flex; align-items: center; @@ -349,3 +343,14 @@ body.scroll-lock { .tweet div .twitter-tweet > iframe { max-height: unset; } + +@media(max-width: 720px) { + .page { + width: calc(100vw - 20px); + margin: 0 10px; + } + div.form-group { + flex-direction: column; + align-items: flex-start; + } +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index 22c8323a..7474800f 100644 --- a/src/index.js +++ b/src/index.js @@ -24,11 +24,6 @@ import VerificationPage from './pages/Verification'; import MessagesPage from './pages/MessagesPage'; import ChatPage from './pages/ChatPage'; -/** - * Nostr websocket managment system - */ -export const System = new NostrSystem(); - /** * HTTP query provider */ diff --git a/src/nostr/Connection.js b/src/nostr/Connection.ts similarity index 65% rename from src/nostr/Connection.js rename to src/nostr/Connection.ts index 40c001e3..30bbb7c2 100644 --- a/src/nostr/Connection.js +++ b/src/nostr/Connection.ts @@ -2,33 +2,60 @@ import * as secp from "@noble/secp256k1"; import { v4 as uuid } from "uuid"; import { Subscriptions } from "./Subscriptions"; -import Event from "./Event"; +import { default as NEvent } from "./Event"; import { DefaultConnectTimeout } from "../Const"; +import { ConnectionStats } from "./ConnectionStats"; +import { RawEvent, TaggedRawEvent } from "."; -export class ConnectionStats { - constructor() { - this.Latency = []; - this.Subs = 0; - this.SubsTimeout = 0; - this.EventsReceived = 0; - this.EventsSent = 0; - this.Disconnects = 0; +export type CustomHook = (state: Readonly) => void; + +/** + * Relay settings + */ +export type RelaySettings = { + read: boolean, + write: boolean +}; + +/** + * Snapshot of connection stats + */ +export type StateSnapshot = { + connected: boolean, + disconnects: number, + avgLatency: number, + events: { + received: number, + send: number } -} +}; export default class Connection { - constructor(addr, options) { + Address: string; + Socket: WebSocket | null; + Pending: Subscriptions[]; + Subscriptions: Map; + Settings: RelaySettings; + ConnectTimeout: number; + Stats: ConnectionStats; + StateHooks: Map; + HasStateChange: boolean; + CurrentState: StateSnapshot; + LastState: Readonly; + IsClosed: boolean; + ReconnectTimer: ReturnType | null; + + constructor(addr: string, options: RelaySettings) { this.Address = addr; this.Socket = null; this.Pending = []; - this.Subscriptions = {}; - this.Read = options?.read || true; - this.Write = options?.write || true; + this.Subscriptions = new Map(); + this.Settings = options; this.ConnectTimeout = DefaultConnectTimeout; this.Stats = new ConnectionStats(); - this.StateHooks = {}; + this.StateHooks = new Map(); this.HasStateChange = true; - this.CurrentState = { + this.CurrentState = { connected: false, disconnects: 0, avgLatency: 0, @@ -58,11 +85,11 @@ export default class Connection { clearTimeout(this.ReconnectTimer); this.ReconnectTimer = null; } - this.Socket.close(); + this.Socket?.close(); this._UpdateState(); } - OnOpen(e) { + OnOpen(e: Event) { this.ConnectTimeout = DefaultConnectTimeout; console.log(`[${this.Address}] Open!`); @@ -72,13 +99,13 @@ export default class Connection { } this.Pending = []; - for (let s of Object.values(this.Subscriptions)) { - this._SendSubscription(s, s.ToObject()); + for (let [_, s] of this.Subscriptions) { + this._SendSubscription(s); } this._UpdateState(); } - OnClose(e) { + OnClose(e: CloseEvent) { if (!this.IsClosed) { this.ConnectTimeout = this.ConnectTimeout * 2; console.log(`[${this.Address}] Closed (${e.reason}), trying again in ${(this.ConnectTimeout / 1000).toFixed(0).toLocaleString()} sec`); @@ -93,7 +120,7 @@ export default class Connection { this._UpdateState(); } - OnMessage(e) { + OnMessage(e: MessageEvent) { if (e.data.length > 0) { let msg = JSON.parse(e.data); let tag = msg[0]; @@ -125,17 +152,16 @@ export default class Connection { } } - OnError(e) { + OnError(e: Event) { console.error(e); this._UpdateState(); } /** * Send event on this connection - * @param {Event} e */ - SendEvent(e) { - if (!this.Write) { + SendEvent(e: NEvent) { + if (!this.Settings.write) { return; } let req = ["EVENT", e.ToObject()]; @@ -146,36 +172,28 @@ export default class Connection { /** * Subscribe to data from this connection - * @param {Subscriptions | Array} sub Subscriptions object */ - AddSubscription(sub) { - if (!this.Read) { + AddSubscription(sub: Subscriptions) { + if (!this.Settings.read) { return; } - let subObj = sub.ToObject(); - if (Object.keys(subObj).length === 0) { - debugger; - throw "CANNOT SEND EMPTY SUB - FIX ME"; - } - - if (this.Subscriptions[sub.Id]) { + if (this.Subscriptions.has(sub.Id)) { return; } - this._SendSubscription(sub, subObj); - this.Subscriptions[sub.Id] = sub; + this._SendSubscription(sub); + this.Subscriptions.set(sub.Id, sub); } /** * Remove a subscription - * @param {any} subId Subscription id to remove */ - RemoveSubscription(subId) { - if (this.Subscriptions[subId]) { + RemoveSubscription(subId: string) { + if (this.Subscriptions.has(subId)) { let req = ["CLOSE", subId]; this._SendJson(req); - delete this.Subscriptions[subId]; + this.Subscriptions.delete(subId); return true; } return false; @@ -183,19 +201,17 @@ export default class Connection { /** * Hook status for connection - * @param {function} fnHook Subscription hook */ - StatusHook(fnHook) { + StatusHook(fnHook: CustomHook) { let id = uuid(); - this.StateHooks[id] = fnHook; + this.StateHooks.set(id, fnHook); return () => { - delete this.StateHooks[id]; + this.StateHooks.delete(id); }; } /** * Returns the current state of this connection - * @returns {any} */ GetState() { if (this.HasStateChange) { @@ -218,24 +234,24 @@ export default class Connection { _NotifyState() { let state = this.GetState(); - for (let h of Object.values(this.StateHooks)) { + for (let [_, h] of this.StateHooks) { h(state); } } - _SendSubscription(sub, subObj) { - let req = ["REQ", sub.Id, subObj]; + _SendSubscription(sub: Subscriptions) { + let req = ["REQ", sub.Id, sub.ToObject()]; if (sub.OrSubs.length > 0) { req = [ ...req, ...sub.OrSubs.map(o => o.ToObject()) ]; } - sub.Started[this.Address] = new Date().getTime(); + sub.Started.set(this.Address, new Date().getTime()); this._SendJson(req); } - _SendJson(obj) { + _SendJson(obj: any) { if (this.Socket?.readyState !== WebSocket.OPEN) { this.Pending.push(obj); return; @@ -244,34 +260,43 @@ export default class Connection { this.Socket.send(json); } - _OnEvent(subId, ev) { - if (this.Subscriptions[subId]) { + _OnEvent(subId: string, ev: RawEvent) { + if (this.Subscriptions.has(subId)) { //this._VerifySig(ev); - ev.relay = this.Address; // tag event with relay - this.Subscriptions[subId].OnEvent(ev); + let tagged: TaggedRawEvent = { + ...ev, + relays: [this.Address] + }; + this.Subscriptions.get(subId)?.OnEvent(tagged); } else { // console.warn(`No subscription for event! ${subId}`); // ignored for now, track as "dropped event" with connection stats } } - _OnEnd(subId) { - let sub = this.Subscriptions[subId]; + _OnEnd(subId: string) { + let sub = this.Subscriptions.get(subId); if (sub) { - sub.Finished[this.Address] = new Date().getTime(); - let responseTime = sub.Finished[this.Address] - sub.Started[this.Address]; - if (responseTime > 10_000) { - console.warn(`[${this.Address}][${subId}] Slow response time ${(responseTime / 1000).toFixed(1)} seconds`); + let now = new Date().getTime(); + let started = sub.Started.get(this.Address); + sub.Finished.set(this.Address, now); + if (started) { + let responseTime = now - started; + if (responseTime > 10_000) { + console.warn(`[${this.Address}][${subId}] Slow response time ${(responseTime / 1000).toFixed(1)} seconds`); + } + this.Stats.Latency.push(responseTime); + } else { + console.warn("No started timestamp!"); } sub.OnEnd(this); - this.Stats.Latency.push(responseTime); this._UpdateState(); } else { console.warn(`No subscription for end! ${subId}`); } } - _VerifySig(ev) { + _VerifySig(ev: RawEvent) { let payload = [ 0, ev.pubkey, @@ -282,6 +307,9 @@ export default class Connection { ]; let payloadData = new TextEncoder().encode(JSON.stringify(payload)); + if (secp.utils.sha256Sync === undefined) { + throw "Cannot verify event, no sync sha256 method"; + } let data = secp.utils.sha256Sync(payloadData); let hash = secp.utils.bytesToHex(data); if (!secp.schnorr.verifySync(ev.sig, hash, ev.pubkey)) { diff --git a/src/nostr/ConnectionStats.ts b/src/nostr/ConnectionStats.ts new file mode 100644 index 00000000..0de56615 --- /dev/null +++ b/src/nostr/ConnectionStats.ts @@ -0,0 +1,44 @@ + +/** + * Stats class for tracking metrics per connection + */ +export class ConnectionStats { + /** + * Last n records of how long between REQ->EOSE + */ + Latency: number[]; + + /** + * Total number of REQ's sent on this connection + */ + Subs: number; + + /** + * Count of REQ which took too long and where abandoned + */ + SubsTimeout: number; + + /** + * Total number of EVENT messages received + */ + EventsReceived: number; + + /** + * Total number of EVENT messages sent + */ + EventsSent: number; + + /** + * Total number of times this connection was lost + */ + Disconnects: number; + + constructor() { + this.Latency = []; + this.Subs = 0; + this.SubsTimeout = 0; + this.EventsReceived = 0; + this.EventsSent = 0; + this.Disconnects = 0; + } +} diff --git a/src/nostr/Event.js b/src/nostr/Event.ts similarity index 57% rename from src/nostr/Event.js rename to src/nostr/Event.ts index 88f07d47..1ac7aa5b 100644 --- a/src/nostr/Event.js +++ b/src/nostr/Event.ts @@ -1,63 +1,66 @@ import * as secp from '@noble/secp256k1'; -import base64 from "@protobufjs/base64" +import * as base64 from "@protobufjs/base64" +import { HexKey, RawEvent, TaggedRawEvent } from '.'; import EventKind from "./EventKind"; import Tag from './Tag'; import Thread from './Thread'; export default class Event { - constructor() { - /** - * The original event - */ - this.Original = null; + /** + * The original event + */ + Original: TaggedRawEvent | null; - /** - * Id of the event - * @type {string} - */ - this.Id = null; + /** + * Id of the event + */ + Id: string - /** - * Pub key of the creator - * @type {string} - */ - this.PubKey = null; + /** + * Pub key of the creator + */ + PubKey: string; - /** - * Timestamp when the event was created - * @type {number} - */ - this.CreatedAt = null; + /** + * Timestamp when the event was created + */ + CreatedAt: number; - /** - * The type of event - * @type {EventKind} - */ - this.Kind = null; + /** + * The type of event + */ + Kind: EventKind; - /** - * A list of metadata tags - * @type {Array} - */ - this.Tags = []; + /** + * A list of metadata tags + */ + Tags: Array; - /** - * Content of the event - * @type {string} - */ - this.Content = null; + /** + * Content of the event + */ + Content: string; - /** - * Signature of this event from the creator - * @type {string} - */ - this.Signature = null; + /** + * Signature of this event from the creator + */ + Signature: string; - /** - * Thread information for this event - * @type {Thread} - */ - this.Thread = null; + /** + * Thread information for this event + */ + Thread: Thread | null; + + constructor(e?: TaggedRawEvent) { + this.Original = e ?? null; + this.Id = e?.id ?? ""; + this.PubKey = e?.pubkey ?? ""; + this.CreatedAt = e?.created_at ?? Math.floor(new Date().getTime() / 1000); + this.Kind = e?.kind ?? EventKind.Unknown; + this.Tags = e?.tags.map((a, i) => new Tag(a, i)) ?? []; + this.Content = e?.content ?? ""; + this.Signature = e?.sig ?? ""; + this.Thread = Thread.ExtractThread(this); } /** @@ -73,9 +76,8 @@ export default class Event { /** * Sign this message with a private key - * @param {string} key Key to sign message with */ - async Sign(key) { + async Sign(key: HexKey) { this.Id = await this.CreateId(); let sig = await secp.schnorr.sign(this.Id, key); @@ -108,49 +110,20 @@ export default class Event { let payloadData = new TextEncoder().encode(JSON.stringify(payload)); let data = await secp.utils.sha256(payloadData); let hash = secp.utils.bytesToHex(data); - if (this.Id !== null && hash !== this.Id) { + if (this.Id !== "" && hash !== this.Id) { console.debug(payload); throw "ID doesnt match!"; } return hash; } - /** - * Does this event have content - * @returns {boolean} - */ - IsContent() { - const ContentKinds = [ - EventKind.TextNote - ]; - return ContentKinds.includes(this.Kind); - } - - static FromObject(obj) { - if (typeof obj !== "object") { - return null; - } - - let ret = new Event(); - ret.Original = obj; - ret.Id = obj.id; - ret.PubKey = obj.pubkey; - ret.CreatedAt = obj.created_at; - ret.Kind = obj.kind; - ret.Tags = obj.tags.map((e, i) => new Tag(e, i)).filter(a => !a.Invalid); - ret.Content = obj.content; - ret.Signature = obj.sig; - ret.Thread = Thread.ExtractThread(ret); - return ret; - } - - ToObject() { + ToObject(): RawEvent { return { id: this.Id, pubkey: this.PubKey, created_at: this.CreatedAt, kind: this.Kind, - tags: this.Tags.sort((a, b) => a.Index - b.Index).map(a => a.ToObject()).filter(a => a !== null), + tags: this.Tags.sort((a, b) => a.Index - b.Index).map(a => a.ToObject()).filter(a => a !== null), content: this.Content, sig: this.Signature }; @@ -158,21 +131,17 @@ export default class Event { /** * Create a new event for a specific pubkey - * @param {String} pubKey */ - static ForPubKey(pubKey) { + static ForPubKey(pubKey: HexKey) { let ev = new Event(); - ev.CreatedAt = parseInt(new Date().getTime() / 1000); ev.PubKey = pubKey; return ev; } /** * Encrypt the message content in place - * @param {string} pubkey - * @param {string} privkey */ - async EncryptDmForPubkey(pubkey, privkey) { + async EncryptDmForPubkey(pubkey: HexKey, privkey: HexKey) { let key = await this._GetDmSharedKey(pubkey, privkey); let iv = window.crypto.getRandomValues(new Uint8Array(16)); let data = new TextEncoder().encode(this.Content); @@ -186,10 +155,8 @@ export default class Event { /** * Decrypt the content of this message in place - * @param {string} privkey - * @param {string} pubkey */ - async DecryptDm(privkey, pubkey) { + async DecryptDm(privkey: HexKey, pubkey: HexKey) { let key = await this._GetDmSharedKey(pubkey, privkey); let cSplit = this.Content.split("?iv="); let data = new Uint8Array(base64.length(cSplit[0])); @@ -205,7 +172,7 @@ export default class Event { this.Content = new TextDecoder().decode(result); } - async _GetDmSharedKey(pubkey, privkey) { + async _GetDmSharedKey(pubkey: HexKey, privkey: HexKey) { let sharedPoint = secp.getSharedSecret(privkey, '02' + pubkey); let sharedX = sharedPoint.slice(1, 33); return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"]) diff --git a/src/nostr/EventKind.js b/src/nostr/EventKind.js deleted file mode 100644 index 42347886..00000000 --- a/src/nostr/EventKind.js +++ /dev/null @@ -1,13 +0,0 @@ -const EventKind = { - Unknown: -1, - SetMetadata: 0, - TextNote: 1, - RecommendServer: 2, - ContactList: 3, // NIP-02 - DirectMessage: 4, // NIP-04 - Deletion: 5, // NIP-09 - Repost: 6, // NIP-18 - Reaction: 7 // NIP-25 -}; - -export default EventKind; \ No newline at end of file diff --git a/src/nostr/EventKind.ts b/src/nostr/EventKind.ts new file mode 100644 index 00000000..d12012b9 --- /dev/null +++ b/src/nostr/EventKind.ts @@ -0,0 +1,13 @@ +const enum EventKind { + Unknown = -1, + SetMetadata = 0, + TextNote = 1, + RecommendServer = 2, + ContactList = 3, // NIP-02 + DirectMessage = 4, // NIP-04 + Deletion = 5, // NIP-09 + Repost = 6, // NIP-18 + Reaction = 7 // NIP-25 +}; + +export default EventKind; \ No newline at end of file diff --git a/src/nostr/Subscriptions.js b/src/nostr/Subscriptions.js deleted file mode 100644 index 8ac07a4d..00000000 --- a/src/nostr/Subscriptions.js +++ /dev/null @@ -1,143 +0,0 @@ -import { v4 as uuid } from "uuid"; -import Connection from "./Connection"; - -export class Subscriptions { - constructor() { - /** - * A unique id for this subscription filter - */ - this.Id = uuid(); - - /** - * a list of event ids or prefixes - */ - this.Ids = new Set(); - - /** - * a list of pubkeys or prefixes, the pubkey of an event must be one of these - */ - this.Authors = new Set(); - - /** - * a list of a kind numbers - */ - this.Kinds = new Set(); - - /** - * a list of event ids that are referenced in an "e" tag - */ - this.ETags = new Set(); - - /** - * a list of pubkeys that are referenced in a "p" tag - */ - this.PTags = new Set(); - - /** - * a timestamp, events must be newer than this to pass - */ - this.Since = NaN; - - /** - * a timestamp, events must be older than this to pass - */ - this.Until = NaN; - - /** - * maximum number of events to be returned in the initial query - */ - this.Limit = NaN; - - /** - * Handler function for this event - */ - this.OnEvent = (e) => { console.warn(`No event handler was set on subscription: ${this.Id}`) }; - - /** - * End of data event - * @param {Connection} c - */ - this.OnEnd = (c) => {}; - - /** - * Collection of OR sub scriptions linked to this - */ - this.OrSubs = []; - - /** - * Start time for this subscription - */ - this.Started = {}; - - /** - * End time for this subscription - */ - this.Finished = {}; - } - - /** - * Adds OR filter subscriptions - * @param {Subscriptions} sub Extra filters - */ - AddSubscription(sub) { - this.OrSubs.push(sub); - } - - /** - * If all relays have responded with EOSE - * @returns {boolean} - */ - IsFinished() { - return Object.keys(this.Started).length === Object.keys(this.Finished).length; - } - - static FromObject(obj) { - let ret = new Subscriptions(); - ret.Ids = new Set(obj.ids); - ret.Authors = new Set(obj.authors); - ret.Kinds = new Set(obj.kinds); - ret.ETags = new Set(obj["#e"]); - ret.PTags = new Set(obj["#p"]); - ret.Since = parseInt(obj.since); - ret.Until = parseInt(obj.until); - ret.Limit = parseInt(obj.limit); - return ret; - } - - ToObject() { - let ret = {}; - if (this.Ids.size > 0) { - ret.ids = Array.from(this.Ids); - } - if (this.Authors.size > 0) { - ret.authors = Array.from(this.Authors); - } - if (this.Kinds.size > 0) { - ret.kinds = Array.from(this.Kinds); - } - if (this.ETags.size > 0) { - ret["#e"] = Array.from(this.ETags); - } - if (this.PTags.size > 0) { - ret["#p"] = Array.from(this.PTags); - } - if (!isNaN(this.Since)) { - ret.since = this.Since; - } - if (!isNaN(this.Until)) { - ret.until = this.Until; - } - if (!isNaN(this.Limit)) { - ret.limit = this.Limit; - } - return ret; - } - - /** - * Split subscription by ids - * @param {number} n How many segments to create - */ - Split(n) { - - } -} \ No newline at end of file diff --git a/src/nostr/Subscriptions.ts b/src/nostr/Subscriptions.ts new file mode 100644 index 00000000..4bbece02 --- /dev/null +++ b/src/nostr/Subscriptions.ts @@ -0,0 +1,139 @@ +import { v4 as uuid } from "uuid"; +import { TaggedRawEvent, RawReqFilter, u256 } from "."; +import Connection from "./Connection"; +import EventKind from "./EventKind"; + +export type NEventHandler = (e: TaggedRawEvent) => void; +export type OnEndHandler = (c: Connection) => void; + +export class Subscriptions { + /** + * A unique id for this subscription filter + */ + Id: u256; + + /** + * a list of event ids or prefixes + */ + Ids: Set | null + + /** + * a list of pubkeys or prefixes, the pubkey of an event must be one of these + */ + Authors: Set | null; + + /** + * a list of a kind numbers + */ + Kinds: Set | null; + + /** + * a list of event ids that are referenced in an "e" tag + */ + ETags: Set | null; + + /** + * a list of pubkeys that are referenced in a "p" tag + */ + PTags: Set | null; + + /** + * a timestamp, events must be newer than this to pass + */ + Since: number | null; + + /** + * a timestamp, events must be older than this to pass + */ + Until: number | null; + + /** + * maximum number of events to be returned in the initial query + */ + Limit: number | null; + + /** + * Handler function for this event + */ + OnEvent: NEventHandler; + + /** + * End of data event + */ + OnEnd: OnEndHandler; + + /** + * Collection of OR sub scriptions linked to this + */ + OrSubs: Array; + + /** + * Start time for this subscription + */ + Started: Map; + + /** + * End time for this subscription + */ + Finished: Map; + + constructor(sub?: RawReqFilter) { + this.Id = uuid(); + this.Ids = sub?.ids ? new Set(sub.ids) : null; + this.Authors = sub?.authors ? new Set(sub.authors) : null; + this.Kinds = sub?.kinds ? new Set(sub.kinds) : null; + this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : null; + this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : null; + this.Since = sub?.since ?? null; + this.Until = sub?.until ?? null; + this.Limit = sub?.limit ?? null; + this.OnEvent = (e) => { console.warn(`No event handler was set on subscription: ${this.Id}`) }; + this.OnEnd = (c) => { }; + this.OrSubs = []; + this.Started = new Map(); + this.Finished = new Map(); + } + + /** + * Adds OR filter subscriptions + */ + AddSubscription(sub: Subscriptions) { + this.OrSubs.push(sub); + } + + /** + * If all relays have responded with EOSE + */ + IsFinished() { + return this.Started.size === this.Finished.size; + } + + ToObject(): RawReqFilter { + let ret: RawReqFilter = {}; + if (this.Ids) { + ret.ids = Array.from(this.Ids); + } + if (this.Authors) { + ret.authors = Array.from(this.Authors); + } + if (this.Kinds) { + ret.kinds = Array.from(this.Kinds); + } + if (this.ETags) { + ret["#e"] = Array.from(this.ETags); + } + if (this.PTags) { + ret["#p"] = Array.from(this.PTags); + } + if (this.Since !== null) { + ret.since = this.Since; + } + if (this.Until !== null) { + ret.until = this.Until; + } + if (this.Limit !== null) { + ret.limit = this.Limit; + } + return ret; + } +} \ No newline at end of file diff --git a/src/nostr/System.js b/src/nostr/System.js deleted file mode 100644 index 0469deda..00000000 --- a/src/nostr/System.js +++ /dev/null @@ -1,98 +0,0 @@ -import Connection from "./Connection"; - -/** - * Manages nostr content retrival system - */ -export class NostrSystem { - constructor() { - this.Sockets = {}; - this.Subscriptions = {}; - this.PendingSubscriptions = []; - } - - /** - * Connect to a NOSTR relay if not already connected - * @param {string} address - */ - ConnectToRelay(address, options) { - try { - if (typeof this.Sockets[address] === "undefined") { - let c = new Connection(address, options); - for (let s of Object.values(this.Subscriptions)) { - c.AddSubscription(s); - } - this.Sockets[address] = c; - } - } catch (e) { - console.error(e); - } - } - - DisconnectRelay(address) { - let c = this.Sockets[address]; - delete this.Sockets[address]; - if (c) { - c.Close(); - } - } - - AddSubscription(sub) { - for (let s of Object.values(this.Sockets)) { - s.AddSubscription(sub); - } - this.Subscriptions[sub.Id] = sub; - } - - RemoveSubscription(subId) { - for (let s of Object.values(this.Sockets)) { - s.RemoveSubscription(subId); - } - delete this.Subscriptions[subId]; - } - - /** - * Send events to writable relays - * @param {Event} ev - */ - BroadcastEvent(ev) { - for (let s of Object.values(this.Sockets)) { - s.SendEvent(ev); - } - } - - /** - * Request/Response pattern - * @param {Subscriptions} sub - * @returns {Array} - */ - RequestSubscription(sub) { - return new Promise((resolve, reject) => { - let events = []; - - // force timeout returning current results - let timeout = setTimeout(() => { - this.RemoveSubscription(sub.Id); - resolve(events); - }, 10_000); - - let onEventPassthrough = sub.OnEvent; - sub.OnEvent = (ev) => { - if (typeof onEventPassthrough === "function") { - onEventPassthrough(ev); - } - if (!events.some(a => a.id === ev.id)) { - events.push(ev); - } - }; - sub.OnEnd = (c) => { - c.RemoveSubscription(sub.Id); - if (sub.IsFinished()) { - clearInterval(timeout); - console.debug(`[${sub.Id}] Finished`); - resolve(events); - } - }; - this.AddSubscription(sub); - }); - } -} \ No newline at end of file diff --git a/src/nostr/System.ts b/src/nostr/System.ts new file mode 100644 index 00000000..297cc623 --- /dev/null +++ b/src/nostr/System.ts @@ -0,0 +1,124 @@ +import { TaggedRawEvent } from "."; +import Connection, { RelaySettings } from "./Connection"; +import Event from "./Event"; +import { Subscriptions } from "./Subscriptions"; + +/** + * Manages nostr content retrival system + */ +export class NostrSystem { + /** + * All currently connected websockets + */ + Sockets: Map; + + /** + * All active subscriptions + */ + Subscriptions: Map; + + /** + * Pending subscriptions to send when sockets become open + */ + PendingSubscriptions: Subscriptions[]; + + constructor() { + this.Sockets = new Map(); + this.Subscriptions = new Map(); + this.PendingSubscriptions = []; + } + + /** + * Connect to a NOSTR relay if not already connected + */ + ConnectToRelay(address: string, options: RelaySettings) { + try { + if (!this.Sockets.has(address)) { + let c = new Connection(address, options); + this.Sockets.set(address, c); + for (let [_, s] of this.Subscriptions) { + c.AddSubscription(s); + } + } + } catch (e) { + console.error(e); + } + } + + /** + * Disconnect from a relay + */ + DisconnectRelay(address: string) { + let c = this.Sockets.get(address); + if (c) { + this.Sockets.delete(address); + c.Close(); + } + } + + AddSubscription(sub: Subscriptions) { + for (let [_, s] of this.Sockets) { + s.AddSubscription(sub); + } + this.Subscriptions.set(sub.Id, sub); + } + + RemoveSubscription(subId: string) { + for (let [_, s] of this.Sockets) { + s.RemoveSubscription(subId); + } + this.Subscriptions.delete(subId); + } + + /** + * Send events to writable relays + */ + BroadcastEvent(ev: Event) { + for (let [_, s] of this.Sockets) { + s.SendEvent(ev); + } + } + + /** + * Request/Response pattern + */ + RequestSubscription(sub: Subscriptions) { + return new Promise((resolve, reject) => { + let events: TaggedRawEvent[] = []; + + // force timeout returning current results + let timeout = setTimeout(() => { + this.RemoveSubscription(sub.Id); + resolve(events); + }, 10_000); + + let onEventPassthrough = sub.OnEvent; + sub.OnEvent = (ev) => { + if (typeof onEventPassthrough === "function") { + onEventPassthrough(ev); + } + if (!events.some(a => a.id === ev.id)) { + events.push(ev); + } else { + let existing = events.find(a => a.id === ev.id); + if (existing) { + for (let v of ev.relays) { + existing.relays.push(v); + } + } + } + }; + sub.OnEnd = (c) => { + c.RemoveSubscription(sub.Id); + if (sub.IsFinished()) { + clearInterval(timeout); + console.debug(`[${sub.Id}] Finished`); + resolve(events); + } + }; + this.AddSubscription(sub); + }); + } +} + +export const System = new NostrSystem(); \ No newline at end of file diff --git a/src/nostr/Tag.js b/src/nostr/Tag.ts similarity index 56% rename from src/nostr/Tag.js rename to src/nostr/Tag.ts index be151232..2342914c 100644 --- a/src/nostr/Tag.js +++ b/src/nostr/Tag.ts @@ -1,11 +1,18 @@ +import { HexKey, RawReqFilter, u256 } from "."; + export default class Tag { - constructor(tag, index) { + Original: string[]; + Key: string; + Event?: u256; + PubKey?: HexKey; + Relay?: string; + Marker?: string; + Index: number; + Invalid: boolean; + + constructor(tag: string[], index: number) { + this.Original = tag; this.Key = tag[0]; - this.Event = null; - this.PubKey = null; - this.Relay = null; - this.Marker = null; - this.Other = null; this.Index = index; this.Invalid = false; @@ -13,8 +20,8 @@ export default class Tag { case "e": { // ["e", , , ] this.Event = tag[1]; - this.Relay = tag.length > 2 ? tag[2] : null; - this.Marker = tag.length > 3 ? tag[3] : null; + this.Relay = tag.length > 2 ? tag[2] : undefined; + this.Marker = tag.length > 3 ? tag[3] : undefined; if (!this.Event) { this.Invalid = true; } @@ -32,27 +39,23 @@ export default class Tag { this.PubKey = tag[1]; break; } - default: { - this.Other = tag; - break; - } } } - ToObject() { + ToObject(): string[] | null { switch (this.Key) { case "e": { let ret = ["e", this.Event, this.Relay, this.Marker]; - let trimEnd = ret.reverse().findIndex(a => a != null); - return ret.reverse().slice(0, ret.length - trimEnd); + let trimEnd = ret.reverse().findIndex(a => a !== undefined); + ret = ret.reverse().slice(0, ret.length - trimEnd); + return ret; } case "p": { - return ["p", this.PubKey]; + return this.PubKey ? ["p", this.PubKey] : null; } default: { - return this.Other; + return this.Original; } } - return null; } } \ No newline at end of file diff --git a/src/nostr/Thread.js b/src/nostr/Thread.ts similarity index 70% rename from src/nostr/Thread.js rename to src/nostr/Thread.ts index 4db8889d..36e6f1b7 100644 --- a/src/nostr/Thread.js +++ b/src/nostr/Thread.ts @@ -1,23 +1,24 @@ -import Event from "./Event"; +import { u256 } from "."; +import { default as NEvent } from "./Event"; import EventKind from "./EventKind"; +import Tag from "./Tag"; export default class Thread { + Root?: Tag; + ReplyTo?: Tag; + Mentions: Array; + PubKeys: Array; + constructor() { - /** @type {Tag} */ - this.Root = null; - /** @type {Tag} */ - this.ReplyTo = null; - /** @type {Array} */ this.Mentions = []; - /** @type {Array} */ this.PubKeys = []; } /** * Extract thread information from an Event - * @param {Event} ev Event to extract thread from + * @param ev Event to extract thread from */ - static ExtractThread(ev) { + static ExtractThread(ev: NEvent) { let isThread = ev.Tags.some(a => a.Key === "e"); if (!isThread) { return null; @@ -26,13 +27,13 @@ export default class Thread { let shouldWriteMarkers = ev.Kind === EventKind.TextNote; let ret = new Thread(); let eTags = ev.Tags.filter(a => a.Key === "e"); - let marked = eTags.some(a => a.Marker !== null); + let marked = eTags.some(a => a.Marker !== undefined); if (!marked) { ret.Root = eTags[0]; - ret.Root.Marker = shouldWriteMarkers ? "root" : null; + ret.Root.Marker = shouldWriteMarkers ? "root" : undefined; if (eTags.length > 1) { ret.ReplyTo = eTags[1]; - ret.ReplyTo.Marker = shouldWriteMarkers ? "reply" : null; + ret.ReplyTo.Marker = shouldWriteMarkers ? "reply" : undefined; } if (eTags.length > 2) { ret.Mentions = eTags.slice(2); @@ -47,7 +48,7 @@ export default class Thread { ret.ReplyTo = reply; ret.Mentions = eTags.filter(a => a.Marker === "mention"); } - ret.PubKeys = [...new Set(ev.Tags.filter(a => a.Key === "p").map(a => a.PubKey))] + ret.PubKeys = Array.from(new Set(ev.Tags.filter(a => a.Key === "p").map(a => a.PubKey))); return ret; } } \ No newline at end of file diff --git a/src/nostr/index.ts b/src/nostr/index.ts index ce45564e..62da4517 100644 --- a/src/nostr/index.ts +++ b/src/nostr/index.ts @@ -1,9 +1,55 @@ -export interface RawEvent { - id: string, - pubkey: string, +export type RawEvent = { + id: u256, + pubkey: HexKey, created_at: number, kind: number, tags: string[][], content: string, sig: string +} + +export interface TaggedRawEvent extends RawEvent { + /** + * A list of relays this event was seen on + */ + relays: string[] +} + +/** + * Basic raw key as hex + */ +export type HexKey = string; + +/** + * A 256bit hex id + */ +export type u256 = string; + +/** + * Raw REQ filter object + */ +export type RawReqFilter = { + ids?: u256[], + authors?: u256[], + kinds?: number[], + "#e"?: u256[], + "#p"?: u256[], + since?: number, + until?: number, + limit?: number +} + +/** + * Medatadata event content + */ +export type UserMetadata = { + name?: string, + display_name?: string, + about?: string, + picture?: string, + website?: string, + banner?: string, + nip05?: string, + lud06?: string, + lud16?: string } \ No newline at end of file diff --git a/src/pages/Layout.js b/src/pages/Layout.js index 1a9a7137..80dc05b6 100644 --- a/src/pages/Layout.js +++ b/src/pages/Layout.js @@ -5,7 +5,7 @@ import { Outlet, useNavigate } from "react-router-dom"; import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { System } from ".." +import { System } from "../nostr/System" import ProfileImage from "../element/ProfileImage"; import { init } from "../state/Login"; import useLoginFeed from "../feed/LoginFeed"; diff --git a/src/pages/Notifications.js b/src/pages/Notifications.js index fdb1f1ef..3d537ed9 100644 --- a/src/pages/Notifications.js +++ b/src/pages/Notifications.js @@ -19,7 +19,7 @@ export default function NotificationsPage() { const etagged = useMemo(() => { return notifications?.filter(a => a.kind === EventKind.Reaction) .map(a => { - let ev = Event.FromObject(a); + let ev = new Event(a); return ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; }) }, [notifications]); @@ -27,12 +27,12 @@ export default function NotificationsPage() { const subEvents = useMemo(() => { let sub = new Subscriptions(); sub.Id = `reactions:${sub.Id}`; - sub.Kinds.add(EventKind.Reaction); + sub.Kinds = new Set([EventKind.Reaction]); sub.ETags = new Set(notifications?.filter(b => b.kind === EventKind.TextNote).map(b => b.id)); if (etagged.length > 0) { let reactionsTo = new Subscriptions(); - reactionsTo.Kinds.add(EventKind.TextNote); + reactionsTo.Kinds = new Set([EventKind.TextNote]); reactionsTo.Ids = new Set(etagged); sub.OrSubs.push(reactionsTo); } @@ -52,7 +52,7 @@ export default function NotificationsPage() { let reactions = otherNotes?.notes?.filter(c => c.tags.find(b => b[0] === "e" && b[1] === a.id)); return } else if (a.kind === EventKind.Reaction) { - let ev = Event.FromObject(a); + let ev = new Event(a); let reactedTo = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; let reactedNote = otherNotes?.notes?.find(c => c.id === reactedTo); return diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index 5d4b7abc..ed2eada9 100644 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -105,57 +105,54 @@ export default function ProfilePage() { } function avatar() { - return ( -
-
-
-
- ) + return ( +
+
+
+
+ ) } function userDetails() { - return ( -
- {username()} - {isMe ? ( -
navigate("/settings")}> - -
- ) : <> -
navigate(`/messages/${hexToBech32("npub", id)}`)}> - -
- - - } - {bio()} -
- ) + return ( +
+ {username()} + {isMe ? ( +
navigate("/settings")}> + +
+ ) : <> +
navigate(`/messages/${hexToBech32("npub", id)}`)}> + +
+ + + } + {bio()} +
+ ) } return ( <>
- {user?.banner && banner } + {user?.banner && banner} {user?.banner ? ( - <> - {avatar()} - {userDetails()} - + <> + {avatar()} + {userDetails()} + ) : ( -
- {avatar()} - {userDetails()} -
+
+ {avatar()} + {userDetails()} +
)}
- { - Object.entries(ProfileTab).map(([k, v]) => { - return
setTab(v)}>{k}
- } - ) - } + {Object.entries(ProfileTab).map(([k, v]) => { + return
setTab(v)}>{k}
+ })}
{tabContent()} diff --git a/src/state/Login.js b/src/state/Login.ts similarity index 61% rename from src/state/Login.js rename to src/state/Login.ts index c27c9f66..aa6c53c6 100644 --- a/src/state/Login.js +++ b/src/state/Login.ts @@ -1,71 +1,87 @@ -import { createSlice } from '@reduxjs/toolkit' +import { createSlice, PayloadAction } from '@reduxjs/toolkit' import * as secp from '@noble/secp256k1'; import { DefaultRelays } from '../Const'; +import { HexKey, RawEvent, TaggedRawEvent } from '../nostr'; +import { RelaySettings } from '../nostr/Connection'; const PrivateKeyItem = "secret"; const PublicKeyItem = "pubkey"; const NotificationsReadItem = "notifications-read"; +interface LoginStore { + /** + * If there is no login + */ + loggedOut?: boolean, + + /** + * Current user private key + */ + privateKey?: HexKey, + + /** + * Current users public key + */ + publicKey?: HexKey, + + /** + * All the logged in users relays + */ + relays: any, + + /** + * Newest relay list timestamp + */ + latestRelays: number, + + /** + * A list of pubkeys this user follows + */ + follows: HexKey[], + + /** + * Notifications for this login session + */ + notifications: TaggedRawEvent[], + + /** + * Timestamp of last read notification + */ + readNotifications: number, + + /** + * Encrypted DM's + */ + dms: TaggedRawEvent[] +}; + +export interface SetRelaysPayload { + relays: any, + createdAt: number +}; + const LoginSlice = createSlice({ name: "Login", - initialState: { - /** - * If there is no login - */ - loggedOut: null, - - /** - * Current user private key - */ - privateKey: null, - - /** - * Current users public key - */ - publicKey: null, - - /** - * Configured relays for this user - */ + initialState: { relays: {}, - - /** - * Newest relay list timestamp - */ - latestRelays: null, - - /** - * A list of pubkeys this user follows - */ + latestRelays: 0, follows: [], - - /** - * Notifications for this login session - */ notifications: [], - - /** - * Timestamp of last read notification - */ readNotifications: 0, - - /** - * Encrypted DM's - */ dms: [] }, reducers: { init: (state) => { - state.privateKey = window.localStorage.getItem(PrivateKeyItem); + state.privateKey = window.localStorage.getItem(PrivateKeyItem) ?? undefined; if (state.privateKey) { window.localStorage.removeItem(PublicKeyItem); // reset nip07 if using private key - state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(state.privateKey, true)); + state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(state.privateKey)); state.loggedOut = false; } else { state.loggedOut = true; } - state.relays = DefaultRelays; + state.relays = Object.fromEntries(DefaultRelays.entries()); // check pub key only let pubKey = window.localStorage.getItem(PublicKeyItem); @@ -75,41 +91,45 @@ const LoginSlice = createSlice({ } // notifications - let readNotif = parseInt(window.localStorage.getItem(NotificationsReadItem)); + let readNotif = parseInt(window.localStorage.getItem(NotificationsReadItem) ?? "0"); if (!isNaN(readNotif)) { state.readNotifications = readNotif; } }, - setPrivateKey: (state, action) => { + setPrivateKey: (state, action: PayloadAction) => { state.loggedOut = false; state.privateKey = action.payload; window.localStorage.setItem(PrivateKeyItem, action.payload); - state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(action.payload, true)); + state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(action.payload)); }, - setPublicKey: (state, action) => { + setPublicKey: (state, action: PayloadAction) => { window.localStorage.setItem(PublicKeyItem, action.payload); state.loggedOut = false; state.publicKey = action.payload; }, - setRelays: (state, action) => { + setRelays: (state, action: PayloadAction) => { let relays = action.payload.relays; let createdAt = action.payload.createdAt; - if(state.latestRelays > createdAt) { + if (state.latestRelays > createdAt) { return; } // filter out non-websocket urls - let filtered = Object.entries(relays) - .filter(a => a[0].startsWith("ws://") || a[0].startsWith("wss://")); + let filtered = new Map(); + for (let [k, v] of Object.entries(relays)) { + if (k.startsWith("wss://") || k.startsWith("ws://")) { + filtered.set(k, v); + } + } - state.relays = Object.fromEntries(filtered); + state.relays = Object.fromEntries(filtered.entries()); state.latestRelays = createdAt; }, - removeRelay: (state, action) => { + removeRelay: (state, action: PayloadAction) => { delete state.relays[action.payload]; state.relays = { ...state.relays }; }, - setFollows: (state, action) => { + setFollows: (state, action: PayloadAction) => { let existing = new Set(state.follows); let update = Array.isArray(action.payload) ? action.payload : [action.payload]; @@ -124,7 +144,7 @@ const LoginSlice = createSlice({ state.follows = Array.from(existing); } }, - addNotifications: (state, action) => { + addNotifications: (state, action: PayloadAction) => { let n = action.payload; if (!Array.isArray(n)) { n = [n]; @@ -143,7 +163,7 @@ const LoginSlice = createSlice({ ]; } }, - addDirectMessage: (state, action) => { + addDirectMessage: (state, action: PayloadAction>) => { let n = action.payload; if (!Array.isArray(n)) { n = [n]; @@ -166,8 +186,8 @@ const LoginSlice = createSlice({ window.localStorage.removeItem(PrivateKeyItem); window.localStorage.removeItem(PublicKeyItem); window.localStorage.removeItem(NotificationsReadItem); - state.privateKey = null; - state.publicKey = null; + state.privateKey = undefined; + state.publicKey = undefined; state.follows = []; state.notifications = []; state.loggedOut = true; @@ -176,10 +196,21 @@ const LoginSlice = createSlice({ }, markNotificationsRead: (state) => { state.readNotifications = new Date().getTime(); - window.localStorage.setItem(NotificationsReadItem, state.readNotifications); + window.localStorage.setItem(NotificationsReadItem, state.readNotifications.toString()); } } }); -export const { init, setPrivateKey, setPublicKey, setRelays, removeRelay, setFollows, addNotifications, addDirectMessage, logout, markNotificationsRead } = LoginSlice.actions; +export const { + init, + setPrivateKey, + setPublicKey, + setRelays, + removeRelay, + setFollows, + addNotifications, + addDirectMessage, + logout, + markNotificationsRead +} = LoginSlice.actions; export const reducer = LoginSlice.reducer; \ No newline at end of file diff --git a/src/state/Store.js b/src/state/Store.ts similarity index 59% rename from src/state/Store.js rename to src/state/Store.ts index ca83e460..9a83ab35 100644 --- a/src/state/Store.js +++ b/src/state/Store.ts @@ -2,11 +2,14 @@ import { configureStore } from "@reduxjs/toolkit"; import { reducer as UsersReducer } from "./Users"; import { reducer as LoginReducer } from "./Login"; -const Store = configureStore({ +const store = configureStore({ reducer: { users: UsersReducer, login: LoginReducer } }); -export default Store; \ No newline at end of file +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch + +export default store; \ No newline at end of file diff --git a/src/state/Users.js b/src/state/Users.ts similarity index 71% rename from src/state/Users.js rename to src/state/Users.ts index 69987b84..32aafe48 100644 --- a/src/state/Users.js +++ b/src/state/Users.ts @@ -1,22 +1,38 @@ -import { createSlice } from '@reduxjs/toolkit' +import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { ProfileCacheExpire } from '../Const'; import { db } from '../db'; +import { HexKey, UserMetadata } from '../nostr'; + +export interface MetadataCache extends UserMetadata { + /** + * When the object was saved in cache + */ + loaded: number, + + /** + * When the source metadata event was created + */ + created: number, + + /** + * The pubkey of the owner of this metadata + */ + pubkey: HexKey +}; + +export interface UsersStore { + pubKeys: HexKey[], + users: any +}; const UsersSlice = createSlice({ name: "Users", - initialState: { - /** - * Set of known pubkeys - */ + initialState: { pubKeys: [], - - /** - * User objects for known pubKeys, populated async - */ users: {}, }, reducers: { - addPubKey: (state, action) => { + addPubKey: (state, action: PayloadAction>) => { let keys = action.payload; if (!Array.isArray(keys)) { keys = [keys]; @@ -32,7 +48,7 @@ const UsersSlice = createSlice({ // load from cache let cache = window.localStorage.getItem(`user:${k}`); if (cache) { - let ud = JSON.parse(cache); + let ud: MetadataCache = JSON.parse(cache); if (ud.loaded > new Date().getTime() - ProfileCacheExpire) { state.users[ud.pubkey] = ud; fromCache = true; @@ -49,7 +65,7 @@ const UsersSlice = createSlice({ } } }, - setUserData: (state, action) => { + setUserData: (state, action: PayloadAction>) => { let ud = action.payload; if (!Array.isArray(ud)) { ud = [ud]; @@ -58,7 +74,7 @@ const UsersSlice = createSlice({ for (let x of ud) { let existing = state.users[x.pubkey]; if (existing) { - if (existing.fromEvent.created_at > x.fromEvent.created_at) { + if (existing.created > x.created) { // prevent patching with older metadata continue; } @@ -82,8 +98,8 @@ const UsersSlice = createSlice({ }; } }, - resetProfile: (state, action) => { - if (state.users[action.payload]) { + resetProfile: (state, action: PayloadAction) => { + if (action.payload in state.users) { delete state.users[action.payload]; state.users = { ...state.users diff --git a/tsconfig.json b/tsconfig.json index 5ecf8630..973f6a2d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "target": "es6", "jsx": "react-jsx", "moduleResolution": "node", "forceConsistentCasingInFileNames": true, diff --git a/yarn.lock b/yarn.lock index c760d63a..29bdfd81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2127,6 +2127,10 @@ integrity sha512-e1DZGD+eH19BnllTWCGXAdrMa2kI53wEMuhn/d+wUmnu8//ZI6BiuK/EPdw07fI4+tlyo5qdPZdXdpkoXHJVOw== dependencies: "@types/react" "*" +"@types/uuid@^9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.0.tgz#53ef263e5239728b56096b0a869595135b7952d2" + integrity sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q== "@types/ws@^8.5.1": version "8.5.4"