refactor: TS

This commit is contained in:
Kieran 2023-01-15 19:40:47 +00:00
parent 9cd24e5623
commit c7e42c1f75
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
43 changed files with 1129 additions and 918 deletions

View File

@ -15,6 +15,7 @@
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2", "@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
"@webscopeio/react-textarea-autocomplete": "^4.9.2", "@webscopeio/react-textarea-autocomplete": "^4.9.2",
"@types/uuid": "^9.0.0",
"bech32": "^2.0.0", "bech32": "^2.0.0",
"dexie": "^3.2.2", "dexie": "^3.2.2",
"dexie-react-hooks": "^1.1.1", "dexie-react-hooks": "^1.1.1",

View File

@ -1,3 +1,4 @@
import { RelaySettings } from "./nostr/Connection";
/** /**
* Websocket re-connect timeout * Websocket re-connect timeout
@ -12,11 +13,11 @@ export const ProfileCacheExpire = (1_000 * 60 * 5);
/** /**
* Default bootstrap relays * Default bootstrap relays
*/ */
export const DefaultRelays = { export const DefaultRelays = new Map<string, RelaySettings>([
"wss://relay.snort.social": { read: true, write: true }, ["wss://relay.snort.social", { read: true, write: true }],
"wss://relay.damus.io": { read: true, write: true }, ["wss://relay.damus.io", { read: true, write: true }],
"wss://nostr-pub.wellorder.net": { read: true, write: true } ["wss://nostr-pub.wellorder.net", { read: true, write: true }],
}; ]);
/** /**
* List of recommended follows for new users * List of recommended follows for new users

View File

@ -1,12 +1,16 @@
import * as secp from "@noble/secp256k1"; import * as secp from "@noble/secp256k1";
import { bech32 } from "bech32"; import { bech32 } from "bech32";
import { HexKey, u256 } from "./nostr";
export async function openFile() { export async function openFile() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let elm = document.createElement("input"); let elm = document.createElement("input");
elm.type = "file"; elm.type = "file";
elm.onchange = (e) => { elm.onchange = (e: Event) => {
resolve(e.target.files[0]); let elm = e.target as HTMLInputElement;
if (elm.files) {
resolve(elm.files[0]);
}
}; };
elm.click(); elm.click();
}); });
@ -15,9 +19,9 @@ export async function openFile() {
/** /**
* Parse bech32 ids * Parse bech32 ids
* https://github.com/nostr-protocol/nips/blob/master/19.md * 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"]; const hrp = ["note", "npub", "nsec"];
try { try {
if (hrp.some(a => id.startsWith(a))) { if (hrp.some(a => id.startsWith(a))) {
@ -27,7 +31,7 @@ export function parseId(id) {
return id; return id;
} }
export function bech32ToHex(str) { export function bech32ToHex(str: string) {
let nKey = bech32.decode(str); let nKey = bech32.decode(str);
let buff = bech32.fromWords(nKey.words); let buff = bech32.fromWords(nKey.words);
return secp.utils.bytesToHex(Uint8Array.from(buff)); return secp.utils.bytesToHex(Uint8Array.from(buff));
@ -35,10 +39,10 @@ export function bech32ToHex(str) {
/** /**
* Decode bech32 to string UTF-8 * Decode bech32 to string UTF-8
* @param {string} str bech32 encoded string * @param str bech32 encoded string
* @returns * @returns
*/ */
export function bech32ToText(str) { export function bech32ToText(str: string) {
let decoded = bech32.decode(str, 1000); let decoded = bech32.decode(str, 1000);
let buf = bech32.fromWords(decoded.words); let buf = bech32.fromWords(decoded.words);
return new TextDecoder().decode(Uint8Array.from(buf)); return new TextDecoder().decode(Uint8Array.from(buf));
@ -46,10 +50,10 @@ export function bech32ToText(str) {
/** /**
* Convert hex note id to bech32 link url * Convert hex note id to bech32 link url
* @param {string} hex * @param hex
* @returns * @returns
*/ */
export function eventLink(hex) { export function eventLink(hex: u256) {
return `/e/${hexToBech32("note", hex)}`; return `/e/${hexToBech32("note", hex)}`;
} }
@ -57,11 +61,11 @@ export function eventLink(hex) {
* Convert hex to bech32 * Convert hex to bech32
* @param {string} hex * @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) { if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 != 0) {
return ""; return "";
} }
try { try {
let buf = secp.utils.hexToBytes(hex); let buf = secp.utils.hexToBytes(hex);
return bech32.encode(hrp, bech32.toWords(buf)); return bech32.encode(hrp, bech32.toWords(buf));
@ -76,7 +80,7 @@ export function hexToBech32(hrp, hex) {
* @param {string} hex * @param {string} hex
* @returns * @returns
*/ */
export function profileLink(hex) { export function profileLink(hex: HexKey) {
return `/p/${hexToBech32("npub", hex)}`; return `/p/${hexToBech32("npub", hex)}`;
} }
@ -93,7 +97,7 @@ export const Reaction = {
* @param {string} content * @param {string} content
* @returns * @returns
*/ */
export function normalizeReaction(content) { export function normalizeReaction(content: string) {
switch (content) { switch (content) {
case "": return Reaction.Positive; case "": return Reaction.Positive;
case "🤙": return Reaction.Positive; case "🤙": return Reaction.Positive;
@ -109,10 +113,10 @@ export function normalizeReaction(content) {
/** /**
* Converts LNURL service to LN Address * Converts LNURL service to LN Address
* @param {string} lnurl * @param lnurl
* @returns * @returns
*/ */
export function extractLnAddress(lnurl) { export function extractLnAddress(lnurl: string) {
// some clients incorrectly set this to LNURL service, patch this // some clients incorrectly set this to LNURL service, patch this
if (lnurl.toLowerCase().startsWith("lnurl")) { if (lnurl.toLowerCase().startsWith("lnurl")) {
let url = bech32ToText(lnurl); let url = bech32ToText(lnurl);

View File

@ -24,9 +24,9 @@ export default function DM(props: DMProps) {
const { ref, inView, entry } = useInView(); const { ref, inView, entry } = useInView();
async function decrypt() { async function decrypt() {
let e = Event.FromObject(props.data); let e = new Event(props.data);
let decrypted = await publisher.decryptDm(e); let decrypted = await publisher.decryptDm(e);
setContent(decrypted); setContent(decrypted || "<ERROR>");
} }
useEffect(() => { useEffect(() => {

View File

@ -9,6 +9,7 @@ import Text from "./Text";
import { eventLink, hexToBech32 } from "../Util"; import { eventLink, hexToBech32 } from "../Util";
import NoteFooter from "./NoteFooter"; import NoteFooter from "./NoteFooter";
import NoteTime from "./NoteTime"; import NoteTime from "./NoteTime";
import EventKind from "../nostr/EventKind";
export default function Note(props) { export default function Note(props) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -17,7 +18,7 @@ export default function Note(props) {
const { data, isThread, reactions, deletion, hightlight } = props const { data, isThread, reactions, deletion, hightlight } = props
const users = useSelector(s => s.users?.users); const users = useSelector(s => s.users?.users);
const ev = dataEvent ?? Event.FromObject(data); const ev = dataEvent ?? new Event(data);
const options = { const options = {
showHeader: true, showHeader: true,
@ -59,7 +60,7 @@ export default function Note(props) {
) )
} }
if (!ev.IsContent()) { if (ev.Kind !== EventKind.TextNote) {
return ( return (
<> <>
<h4>Unknown event kind: {ev.Kind}</h4> <h4>Unknown event kind: {ev.Kind}</h4>

View File

@ -9,7 +9,7 @@ import { useMemo } from "react";
import NoteTime from "./NoteTime"; import NoteTime from "./NoteTime";
export default function NoteReaction(props) { 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(() => { const refEvent = useMemo(() => {
if (ev) { if (ev) {

View File

@ -10,7 +10,7 @@ export default function Thread(props) {
const thisEvent = props.this; const thisEvent = props.this;
/** @type {Array<Event>} */ /** @type {Array<Event>} */
const notes = props.notes?.map(a => Event.FromObject(a)); const notes = props.notes?.map(a => new Event(a));
// root note has no thread info // root note has no thread info
const root = useMemo(() => notes.find(a => a.Thread === null), [notes]); const root = useMemo(() => notes.find(a => a.Thread === null), [notes]);

View File

@ -25,7 +25,7 @@ export default function Timeline({ global, pubkeys }) {
} }
case EventKind.Reaction: case EventKind.Reaction:
case EventKind.Repost: { case EventKind.Repost: {
return <NoteReaction data={e} key={e.id}/> return <NoteReaction data={e} key={e.id} />
} }
} }
} }

View File

@ -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 "<CANT DECRYPT>";
}
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 "<DECRYPTION FAILED>";
}
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;
}
};

259
src/feed/EventPublisher.ts Normal file
View File

@ -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<HexKey>,
signEvent: (event: RawEvent) => Promise<RawEvent>,
getRelays: () => Promise<[[string, { read: boolean, write: boolean }]]>,
nip04: {
encrypt: (pubkey: HexKey, content: string) => Promise<string>,
decrypt: (pubkey: HexKey, content: string) => Promise<string>
}
}
}
}
export default function useEventPublisher() {
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
const relays = useSelector<RootState>(s => s.login.relays);
const hasNip07 = 'nostr' in window;
async function signEvent(ev: NEvent): Promise<NEvent> {
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<string | undefined> => {
if (pubKey) {
if (note.PubKey !== pubKey && !note.Tags.some(a => a.PubKey === pubKey)) {
return "<CANT DECRYPT>";
}
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 "<DECRYPTION FAILED>";
}
}
},
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<any>) => {
while (isNip07Busy) {
await delay(10);
}
isNip07Busy = true;
try {
return await then();
} finally {
isNip07Busy = false;
}
};

View File

@ -1,14 +1,15 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { HexKey } from "../nostr";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import { Subscriptions } from "../nostr/Subscriptions"; import { Subscriptions } from "../nostr/Subscriptions";
import useSubscription from "./Subscription"; import useSubscription from "./Subscription";
export default function useFollowersFeed(pubkey) { export default function useFollowersFeed(pubkey: HexKey) {
const sub = useMemo(() => { const sub = useMemo(() => {
let x = new Subscriptions(); let x = new Subscriptions();
x.Id = "followers"; x.Id = "followers";
x.Kinds.add(EventKind.ContactList); x.Kinds = new Set([EventKind.ContactList]);
x.PTags.add(pubkey); x.PTags = new Set([pubkey]);
return x; return x;
}, [pubkey]); }, [pubkey]);

View File

@ -1,14 +1,15 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { HexKey } from "../nostr";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import { Subscriptions } from "../nostr/Subscriptions"; import { Subscriptions } from "../nostr/Subscriptions";
import useSubscription from "./Subscription"; import useSubscription from "./Subscription";
export default function useFollowsFeed(pubkey) { export default function useFollowsFeed(pubkey: HexKey) {
const sub = useMemo(() => { const sub = useMemo(() => {
let x = new Subscriptions(); let x = new Subscriptions();
x.Id = "follows"; x.Id = "follows";
x.Kinds.add(EventKind.ContactList); x.Kinds = new Set([EventKind.ContactList]);
x.Authors.add(pubkey); x.Authors = new Set([pubkey]);
return x; return x;
}, [pubkey]); }, [pubkey]);

View File

@ -1,8 +1,10 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { HexKey } from "../nostr";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import { Subscriptions } from "../nostr/Subscriptions"; import { Subscriptions } from "../nostr/Subscriptions";
import { addDirectMessage, addNotifications, setFollows, setRelays } from "../state/Login"; import { addDirectMessage, addNotifications, setFollows, setRelays } from "../state/Login";
import { RootState } from "../state/Store";
import { setUserData } from "../state/Users"; import { setUserData } from "../state/Users";
import { db } from "../db"; import { db } from "../db";
import useSubscription from "./Subscription"; import useSubscription from "./Subscription";
@ -13,7 +15,7 @@ import { mapEventToProfile } from "./UsersFeed";
*/ */
export default function useLoginFeed() { export default function useLoginFeed() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [pubKey, readNotifications] = useSelector(s => [s.login.publicKey, s.login.readNotifications]); const [pubKey, readNotifications] = useSelector<RootState, [HexKey | undefined, number]>(s => [s.login.publicKey, s.login.readNotifications]);
const sub = useMemo(() => { const sub = useMemo(() => {
if (!pubKey) { if (!pubKey) {
@ -22,29 +24,29 @@ export default function useLoginFeed() {
let sub = new Subscriptions(); let sub = new Subscriptions();
sub.Id = `login:${sub.Id}`; sub.Id = `login:${sub.Id}`;
sub.Authors.add(pubKey); sub.Authors = new Set([pubKey]);
sub.Kinds.add(EventKind.ContactList); sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata, EventKind.DirectMessage]);
sub.Kinds.add(EventKind.SetMetadata);
sub.Kinds.add(EventKind.DirectMessage);
let notifications = new Subscriptions(); let notifications = new Subscriptions();
notifications.Kinds.add(EventKind.TextNote); notifications.Kinds = new Set([EventKind.TextNote, EventKind.DirectMessage]);
notifications.Kinds.add(EventKind.DirectMessage); notifications.PTags = new Set([pubKey]);
notifications.PTags.add(pubKey);
notifications.Limit = 100; notifications.Limit = 100;
sub.AddSubscription(notifications); sub.AddSubscription(notifications);
return sub; return sub;
}, [pubKey]); }, [pubKey]);
const { notes } = useSubscription(sub, { leaveOpen: true }); const main = useSubscription(sub, { leaveOpen: true });
useEffect(() => { useEffect(() => {
let contactList = notes.filter(a => a.kind === EventKind.ContactList); let contactList = main.notes.filter(a => a.kind === EventKind.ContactList);
let notifications = notes.filter(a => a.kind === EventKind.TextNote); let notifications = main.notes.filter(a => a.kind === EventKind.TextNote);
let metadata = notes.filter(a => a.kind === EventKind.SetMetadata) 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 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) { for (let cl of contactList) {
if (cl.content !== "") { if (cl.content !== "") {
@ -68,5 +70,5 @@ export default function useLoginFeed() {
}) })
db.users.bulkPut(metadata); db.users.bulkPut(metadata);
dispatch(addDirectMessage(dms)); dispatch(addDirectMessage(dms));
}, [notes]); }, [main]);
} }

View File

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

18
src/feed/ProfileFeed.ts Normal file
View File

@ -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<RootState, MetadataCache>(s => s.users.users[pubKey]);
useEffect(() => {
if (pubKey) {
dispatch(addPubKey(pubKey));
}
}, [pubKey]);
return user;
}

View File

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

11
src/feed/RelayState.ts Normal file
View File

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

View File

@ -1,8 +1,17 @@
import { useEffect, useReducer } from "react"; import { useEffect, useReducer } from "react";
import { System } from ".."; import { System } from "../nostr/System";
import { TaggedRawEvent } from "../nostr";
import { Subscriptions } from "../nostr/Subscriptions"; import { Subscriptions } from "../nostr/Subscriptions";
function notesReducer(state, ev) { export type NoteStore = {
notes: Array<TaggedRawEvent>
};
export type UseSubscriptionOptions = {
leaveOpen: boolean
}
function notesReducer(state: NoteStore, ev: TaggedRawEvent) {
if (state.notes.some(a => a.id === ev.id)) { if (state.notes.some(a => a.id === ev.id)) {
return state; return state;
} }
@ -21,13 +30,8 @@ function notesReducer(state, ev) {
* @param {any} opt * @param {any} opt
* @returns * @returns
*/ */
export default function useSubscription(sub, opt) { export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions) {
const [state, dispatch] = useReducer(notesReducer, { notes: [] }); const [state, dispatch] = useReducer(notesReducer, <NoteStore>{ notes: [] });
const options = {
leaveOpen: false,
...opt
};
useEffect(() => { useEffect(() => {
if (sub) { if (sub) {
@ -35,7 +39,7 @@ export default function useSubscription(sub, opt) {
dispatch(e); dispatch(e);
}; };
if (!options.leaveOpen) { if (!(options?.leaveOpen ?? false)) {
sub.OnEnd = (c) => { sub.OnEnd = (c) => {
c.RemoveSubscription(sub.Id); c.RemoveSubscription(sub.Id);
if (sub.IsFinished()) { if (sub.IsFinished()) {

View File

@ -1,19 +1,18 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { u256 } from "../nostr";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import { Subscriptions } from "../nostr/Subscriptions"; import { Subscriptions } from "../nostr/Subscriptions";
import useSubscription from "./Subscription"; import useSubscription from "./Subscription";
export default function useThreadFeed(id) { export default function useThreadFeed(id: u256) {
const sub = useMemo(() => { const sub = useMemo(() => {
const thisSub = new Subscriptions(); const thisSub = new Subscriptions();
thisSub.Id = `thread:${id.substring(0, 8)}`; thisSub.Id = `thread:${id.substring(0, 8)}`;
thisSub.Ids.add(id); thisSub.Ids = new Set([id]);
// get replies to this event // get replies to this event
const subRelated = new Subscriptions(); const subRelated = new Subscriptions();
subRelated.Kinds.add(EventKind.Reaction); subRelated.Kinds = new Set([EventKind.Reaction, EventKind.TextNote, EventKind.Deletion]);
subRelated.Kinds.add(EventKind.TextNote);
subRelated.Kinds.add(EventKind.Deletion);
subRelated.ETags = thisSub.Ids; subRelated.ETags = thisSub.Ids;
thisSub.AddSubscription(subRelated); thisSub.AddSubscription(subRelated);
@ -28,6 +27,7 @@ export default function useThreadFeed(id) {
if (thisNote) { if (thisNote) {
let otherSubs = new Subscriptions(); let otherSubs = new Subscriptions();
otherSubs.Id = `thread-related:${id.substring(0, 8)}`; otherSubs.Id = `thread-related:${id.substring(0, 8)}`;
otherSubs.Ids = new Set();
for (let e of thisNote.tags.filter(a => a[0] === "e")) { for (let e of thisNote.tags.filter(a => a[0] === "e")) {
otherSubs.Ids.add(e[1]); otherSubs.Ids.add(e[1]);
} }
@ -37,15 +37,14 @@ export default function useThreadFeed(id) {
} }
let relatedSubs = new Subscriptions(); let relatedSubs = new Subscriptions();
relatedSubs.Kinds.add(EventKind.Reaction); relatedSubs.Kinds = new Set([EventKind.Reaction, EventKind.TextNote, EventKind.Deletion]);
relatedSubs.Kinds.add(EventKind.TextNote);
relatedSubs.Kinds.add(EventKind.Deletion);
relatedSubs.ETags = otherSubs.Ids; relatedSubs.ETags = otherSubs.Ids;
otherSubs.AddSubscription(relatedSubs); otherSubs.AddSubscription(relatedSubs);
return otherSubs; return otherSubs;
} }
}, [main.notes]); return null;
}, [main]);
const others = useSubscription(relatedThisSub, { leaveOpen: true }); const others = useSubscription(relatedThisSub, { leaveOpen: true });

View File

@ -1,9 +1,10 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { HexKey } from "../nostr";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import { Subscriptions } from "../nostr/Subscriptions"; import { Subscriptions } from "../nostr/Subscriptions";
import useSubscription from "./Subscription"; import useSubscription from "./Subscription";
export default function useTimelineFeed(pubKeys, global = false) { export default function useTimelineFeed(pubKeys: HexKey | Array<HexKey>, global: boolean = false) {
const subTab = global ? "global" : "follows"; const subTab = global ? "global" : "follows";
const sub = useMemo(() => { const sub = useMemo(() => {
if (!Array.isArray(pubKeys)) { if (!Array.isArray(pubKeys)) {
@ -17,8 +18,7 @@ export default function useTimelineFeed(pubKeys, global = false) {
let sub = new Subscriptions(); let sub = new Subscriptions();
sub.Id = `timeline:${subTab}`; sub.Id = `timeline:${subTab}`;
sub.Authors = new Set(global ? [] : pubKeys); sub.Authors = new Set(global ? [] : pubKeys);
sub.Kinds.add(EventKind.TextNote); sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
sub.Kinds.add(EventKind.Repost);
sub.Limit = 20; sub.Limit = 20;
return sub; return sub;
@ -31,8 +31,7 @@ export default function useTimelineFeed(pubKeys, global = false) {
if (main.notes.length > 0) { if (main.notes.length > 0) {
let sub = new Subscriptions(); let sub = new Subscriptions();
sub.Id = `timeline-related:${subTab}`; sub.Id = `timeline-related:${subTab}`;
sub.Kinds.add(EventKind.Reaction); sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion]);
sub.Kinds.add(EventKind.Deletion);
sub.ETags = new Set(main.notes.map(a => a.id)); sub.ETags = new Set(main.notes.map(a => a.id));
return sub; return sub;

View File

@ -1,21 +1,23 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { ProfileCacheExpire } from "../Const"; import { ProfileCacheExpire } from "../Const";
import { HexKey, TaggedRawEvent, UserMetadata } from "../nostr";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import { db } from "../db"; import { db } from "../db";
import { Subscriptions } from "../nostr/Subscriptions"; 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"; import useSubscription from "./Subscription";
export default function useUsersCache() { export default function useUsersCache() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const pKeys = useSelector(s => s.users.pubKeys); const pKeys = useSelector<RootState, HexKey[]>(s => s.users.pubKeys);
const users = useSelector(s => s.users.users); const users = useSelector<RootState, any>(s => s.users.users);
function isUserCached(id) { function isUserCached(id: HexKey) {
let expire = new Date().getTime() - ProfileCacheExpire; let expire = new Date().getTime() - ProfileCacheExpire;
let u = users[id]; let u = users[id];
return u && u.loaded > expire; return u !== undefined && u.loaded > expire;
} }
const sub = useMemo(() => { const sub = useMemo(() => {
@ -27,7 +29,7 @@ export default function useUsersCache() {
let sub = new Subscriptions(); let sub = new Subscriptions();
sub.Id = `profiles:${sub.Id}`; sub.Id = `profiles:${sub.Id}`;
sub.Authors = new Set(needProfiles.slice(0, 20)); sub.Authors = new Set(needProfiles.slice(0, 20));
sub.Kinds.add(EventKind.SetMetadata); sub.Kinds = new Set([EventKind.SetMetadata]);
return sub; return sub;
}, [pKeys]); }, [pKeys]);
@ -35,23 +37,26 @@ export default function useUsersCache() {
const results = useSubscription(sub); const results = useSubscription(sub);
useEffect(() => { useEffect(() => {
const userData = results.notes.map(a => mapEventToProfile(a)); let profiles: MetadataCache[] = results.notes
dispatch(setUserData(userData)); .map(a => mapEventToProfile(a))
const profiles = results.notes.map(ev => { .filter(a => a !== undefined)
return {...JSON.parse(ev.content), pubkey: ev.pubkey } .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]); }, [results]);
return results; return results;
} }
export function mapEventToProfile(ev) { export function mapEventToProfile(ev: TaggedRawEvent): MetadataCache | undefined {
try { try {
let data = JSON.parse(ev.content); let data: UserMetadata = JSON.parse(ev.content);
return { return {
pubkey: ev.pubkey, pubkey: ev.pubkey,
fromEvent: ev, created: ev.created_at,
loaded: new Date().getTime(), loaded: new Date().getTime(),
...data ...data
}; };

View File

@ -43,6 +43,7 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: var(--bg-color); background-color: var(--bg-color);
color: var(--font-color); color: var(--font-color);
font-size: 14px;
} }
code { code {
@ -213,13 +214,6 @@ span.pill:hover {
cursor: pointer; cursor: pointer;
} }
@media(max-width: 720px) {
.page {
width: calc(100vw - 20px);
margin: 0 10px;
}
}
div.form-group { div.form-group {
display: flex; display: flex;
align-items: center; align-items: center;
@ -349,3 +343,14 @@ body.scroll-lock {
.tweet div .twitter-tweet > iframe { .tweet div .twitter-tweet > iframe {
max-height: unset; 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;
}
}

View File

@ -24,11 +24,6 @@ import VerificationPage from './pages/Verification';
import MessagesPage from './pages/MessagesPage'; import MessagesPage from './pages/MessagesPage';
import ChatPage from './pages/ChatPage'; import ChatPage from './pages/ChatPage';
/**
* Nostr websocket managment system
*/
export const System = new NostrSystem();
/** /**
* HTTP query provider * HTTP query provider
*/ */

View File

@ -2,33 +2,60 @@ import * as secp from "@noble/secp256k1";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { Subscriptions } from "./Subscriptions"; import { Subscriptions } from "./Subscriptions";
import Event from "./Event"; import { default as NEvent } from "./Event";
import { DefaultConnectTimeout } from "../Const"; import { DefaultConnectTimeout } from "../Const";
import { ConnectionStats } from "./ConnectionStats";
import { RawEvent, TaggedRawEvent } from ".";
export class ConnectionStats { export type CustomHook = (state: Readonly<StateSnapshot>) => void;
constructor() {
this.Latency = []; /**
this.Subs = 0; * Relay settings
this.SubsTimeout = 0; */
this.EventsReceived = 0; export type RelaySettings = {
this.EventsSent = 0; read: boolean,
this.Disconnects = 0; write: boolean
};
/**
* Snapshot of connection stats
*/
export type StateSnapshot = {
connected: boolean,
disconnects: number,
avgLatency: number,
events: {
received: number,
send: number
} }
} };
export default class Connection { export default class Connection {
constructor(addr, options) { Address: string;
Socket: WebSocket | null;
Pending: Subscriptions[];
Subscriptions: Map<string, Subscriptions>;
Settings: RelaySettings;
ConnectTimeout: number;
Stats: ConnectionStats;
StateHooks: Map<string, CustomHook>;
HasStateChange: boolean;
CurrentState: StateSnapshot;
LastState: Readonly<StateSnapshot>;
IsClosed: boolean;
ReconnectTimer: ReturnType<typeof setTimeout> | null;
constructor(addr: string, options: RelaySettings) {
this.Address = addr; this.Address = addr;
this.Socket = null; this.Socket = null;
this.Pending = []; this.Pending = [];
this.Subscriptions = {}; this.Subscriptions = new Map();
this.Read = options?.read || true; this.Settings = options;
this.Write = options?.write || true;
this.ConnectTimeout = DefaultConnectTimeout; this.ConnectTimeout = DefaultConnectTimeout;
this.Stats = new ConnectionStats(); this.Stats = new ConnectionStats();
this.StateHooks = {}; this.StateHooks = new Map();
this.HasStateChange = true; this.HasStateChange = true;
this.CurrentState = { this.CurrentState = <StateSnapshot>{
connected: false, connected: false,
disconnects: 0, disconnects: 0,
avgLatency: 0, avgLatency: 0,
@ -58,11 +85,11 @@ export default class Connection {
clearTimeout(this.ReconnectTimer); clearTimeout(this.ReconnectTimer);
this.ReconnectTimer = null; this.ReconnectTimer = null;
} }
this.Socket.close(); this.Socket?.close();
this._UpdateState(); this._UpdateState();
} }
OnOpen(e) { OnOpen(e: Event) {
this.ConnectTimeout = DefaultConnectTimeout; this.ConnectTimeout = DefaultConnectTimeout;
console.log(`[${this.Address}] Open!`); console.log(`[${this.Address}] Open!`);
@ -72,13 +99,13 @@ export default class Connection {
} }
this.Pending = []; this.Pending = [];
for (let s of Object.values(this.Subscriptions)) { for (let [_, s] of this.Subscriptions) {
this._SendSubscription(s, s.ToObject()); this._SendSubscription(s);
} }
this._UpdateState(); this._UpdateState();
} }
OnClose(e) { OnClose(e: CloseEvent) {
if (!this.IsClosed) { if (!this.IsClosed) {
this.ConnectTimeout = this.ConnectTimeout * 2; this.ConnectTimeout = this.ConnectTimeout * 2;
console.log(`[${this.Address}] Closed (${e.reason}), trying again in ${(this.ConnectTimeout / 1000).toFixed(0).toLocaleString()} sec`); 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(); this._UpdateState();
} }
OnMessage(e) { OnMessage(e: MessageEvent<any>) {
if (e.data.length > 0) { if (e.data.length > 0) {
let msg = JSON.parse(e.data); let msg = JSON.parse(e.data);
let tag = msg[0]; let tag = msg[0];
@ -125,17 +152,16 @@ export default class Connection {
} }
} }
OnError(e) { OnError(e: Event) {
console.error(e); console.error(e);
this._UpdateState(); this._UpdateState();
} }
/** /**
* Send event on this connection * Send event on this connection
* @param {Event} e
*/ */
SendEvent(e) { SendEvent(e: NEvent) {
if (!this.Write) { if (!this.Settings.write) {
return; return;
} }
let req = ["EVENT", e.ToObject()]; let req = ["EVENT", e.ToObject()];
@ -146,36 +172,28 @@ export default class Connection {
/** /**
* Subscribe to data from this connection * Subscribe to data from this connection
* @param {Subscriptions | Array<Subscriptions>} sub Subscriptions object
*/ */
AddSubscription(sub) { AddSubscription(sub: Subscriptions) {
if (!this.Read) { if (!this.Settings.read) {
return; return;
} }
let subObj = sub.ToObject(); if (this.Subscriptions.has(sub.Id)) {
if (Object.keys(subObj).length === 0) {
debugger;
throw "CANNOT SEND EMPTY SUB - FIX ME";
}
if (this.Subscriptions[sub.Id]) {
return; return;
} }
this._SendSubscription(sub, subObj); this._SendSubscription(sub);
this.Subscriptions[sub.Id] = sub; this.Subscriptions.set(sub.Id, sub);
} }
/** /**
* Remove a subscription * Remove a subscription
* @param {any} subId Subscription id to remove
*/ */
RemoveSubscription(subId) { RemoveSubscription(subId: string) {
if (this.Subscriptions[subId]) { if (this.Subscriptions.has(subId)) {
let req = ["CLOSE", subId]; let req = ["CLOSE", subId];
this._SendJson(req); this._SendJson(req);
delete this.Subscriptions[subId]; this.Subscriptions.delete(subId);
return true; return true;
} }
return false; return false;
@ -183,19 +201,17 @@ export default class Connection {
/** /**
* Hook status for connection * Hook status for connection
* @param {function} fnHook Subscription hook
*/ */
StatusHook(fnHook) { StatusHook(fnHook: CustomHook) {
let id = uuid(); let id = uuid();
this.StateHooks[id] = fnHook; this.StateHooks.set(id, fnHook);
return () => { return () => {
delete this.StateHooks[id]; this.StateHooks.delete(id);
}; };
} }
/** /**
* Returns the current state of this connection * Returns the current state of this connection
* @returns {any}
*/ */
GetState() { GetState() {
if (this.HasStateChange) { if (this.HasStateChange) {
@ -218,24 +234,24 @@ export default class Connection {
_NotifyState() { _NotifyState() {
let state = this.GetState(); let state = this.GetState();
for (let h of Object.values(this.StateHooks)) { for (let [_, h] of this.StateHooks) {
h(state); h(state);
} }
} }
_SendSubscription(sub, subObj) { _SendSubscription(sub: Subscriptions) {
let req = ["REQ", sub.Id, subObj]; let req = ["REQ", sub.Id, sub.ToObject()];
if (sub.OrSubs.length > 0) { if (sub.OrSubs.length > 0) {
req = [ req = [
...req, ...req,
...sub.OrSubs.map(o => o.ToObject()) ...sub.OrSubs.map(o => o.ToObject())
]; ];
} }
sub.Started[this.Address] = new Date().getTime(); sub.Started.set(this.Address, new Date().getTime());
this._SendJson(req); this._SendJson(req);
} }
_SendJson(obj) { _SendJson(obj: any) {
if (this.Socket?.readyState !== WebSocket.OPEN) { if (this.Socket?.readyState !== WebSocket.OPEN) {
this.Pending.push(obj); this.Pending.push(obj);
return; return;
@ -244,34 +260,43 @@ export default class Connection {
this.Socket.send(json); this.Socket.send(json);
} }
_OnEvent(subId, ev) { _OnEvent(subId: string, ev: RawEvent) {
if (this.Subscriptions[subId]) { if (this.Subscriptions.has(subId)) {
//this._VerifySig(ev); //this._VerifySig(ev);
ev.relay = this.Address; // tag event with relay let tagged: TaggedRawEvent = {
this.Subscriptions[subId].OnEvent(ev); ...ev,
relays: [this.Address]
};
this.Subscriptions.get(subId)?.OnEvent(tagged);
} else { } else {
// console.warn(`No subscription for event! ${subId}`); // console.warn(`No subscription for event! ${subId}`);
// ignored for now, track as "dropped event" with connection stats // ignored for now, track as "dropped event" with connection stats
} }
} }
_OnEnd(subId) { _OnEnd(subId: string) {
let sub = this.Subscriptions[subId]; let sub = this.Subscriptions.get(subId);
if (sub) { if (sub) {
sub.Finished[this.Address] = new Date().getTime(); let now = new Date().getTime();
let responseTime = sub.Finished[this.Address] - sub.Started[this.Address]; let started = sub.Started.get(this.Address);
if (responseTime > 10_000) { sub.Finished.set(this.Address, now);
console.warn(`[${this.Address}][${subId}] Slow response time ${(responseTime / 1000).toFixed(1)} seconds`); 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); sub.OnEnd(this);
this.Stats.Latency.push(responseTime);
this._UpdateState(); this._UpdateState();
} else { } else {
console.warn(`No subscription for end! ${subId}`); console.warn(`No subscription for end! ${subId}`);
} }
} }
_VerifySig(ev) { _VerifySig(ev: RawEvent) {
let payload = [ let payload = [
0, 0,
ev.pubkey, ev.pubkey,
@ -282,6 +307,9 @@ export default class Connection {
]; ];
let payloadData = new TextEncoder().encode(JSON.stringify(payload)); 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 data = secp.utils.sha256Sync(payloadData);
let hash = secp.utils.bytesToHex(data); let hash = secp.utils.bytesToHex(data);
if (!secp.schnorr.verifySync(ev.sig, hash, ev.pubkey)) { if (!secp.schnorr.verifySync(ev.sig, hash, ev.pubkey)) {

View File

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

View File

@ -1,63 +1,66 @@
import * as secp from '@noble/secp256k1'; 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 EventKind from "./EventKind";
import Tag from './Tag'; import Tag from './Tag';
import Thread from './Thread'; import Thread from './Thread';
export default class Event { export default class Event {
constructor() { /**
/** * The original event
* The original event */
*/ Original: TaggedRawEvent | null;
this.Original = null;
/** /**
* Id of the event * Id of the event
* @type {string} */
*/ Id: string
this.Id = null;
/** /**
* Pub key of the creator * Pub key of the creator
* @type {string} */
*/ PubKey: string;
this.PubKey = null;
/** /**
* Timestamp when the event was created * Timestamp when the event was created
* @type {number} */
*/ CreatedAt: number;
this.CreatedAt = null;
/** /**
* The type of event * The type of event
* @type {EventKind} */
*/ Kind: EventKind;
this.Kind = null;
/** /**
* A list of metadata tags * A list of metadata tags
* @type {Array<Tag>} */
*/ Tags: Array<Tag>;
this.Tags = [];
/** /**
* Content of the event * Content of the event
* @type {string} */
*/ Content: string;
this.Content = null;
/** /**
* Signature of this event from the creator * Signature of this event from the creator
* @type {string} */
*/ Signature: string;
this.Signature = null;
/** /**
* Thread information for this event * Thread information for this event
* @type {Thread} */
*/ Thread: Thread | null;
this.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 * 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(); this.Id = await this.CreateId();
let sig = await secp.schnorr.sign(this.Id, key); 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 payloadData = new TextEncoder().encode(JSON.stringify(payload));
let data = await secp.utils.sha256(payloadData); let data = await secp.utils.sha256(payloadData);
let hash = secp.utils.bytesToHex(data); let hash = secp.utils.bytesToHex(data);
if (this.Id !== null && hash !== this.Id) { if (this.Id !== "" && hash !== this.Id) {
console.debug(payload); console.debug(payload);
throw "ID doesnt match!"; throw "ID doesnt match!";
} }
return hash; return hash;
} }
/** ToObject(): RawEvent {
* 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() {
return { return {
id: this.Id, id: this.Id,
pubkey: this.PubKey, pubkey: this.PubKey,
created_at: this.CreatedAt, created_at: this.CreatedAt,
kind: this.Kind, kind: this.Kind,
tags: this.Tags.sort((a, b) => a.Index - b.Index).map(a => a.ToObject()).filter(a => a !== null), tags: <string[][]>this.Tags.sort((a, b) => a.Index - b.Index).map(a => a.ToObject()).filter(a => a !== null),
content: this.Content, content: this.Content,
sig: this.Signature sig: this.Signature
}; };
@ -158,21 +131,17 @@ export default class Event {
/** /**
* Create a new event for a specific pubkey * Create a new event for a specific pubkey
* @param {String} pubKey
*/ */
static ForPubKey(pubKey) { static ForPubKey(pubKey: HexKey) {
let ev = new Event(); let ev = new Event();
ev.CreatedAt = parseInt(new Date().getTime() / 1000);
ev.PubKey = pubKey; ev.PubKey = pubKey;
return ev; return ev;
} }
/** /**
* Encrypt the message content in place * 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 key = await this._GetDmSharedKey(pubkey, privkey);
let iv = window.crypto.getRandomValues(new Uint8Array(16)); let iv = window.crypto.getRandomValues(new Uint8Array(16));
let data = new TextEncoder().encode(this.Content); let data = new TextEncoder().encode(this.Content);
@ -186,10 +155,8 @@ export default class Event {
/** /**
* Decrypt the content of this message in place * 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 key = await this._GetDmSharedKey(pubkey, privkey);
let cSplit = this.Content.split("?iv="); let cSplit = this.Content.split("?iv=");
let data = new Uint8Array(base64.length(cSplit[0])); let data = new Uint8Array(base64.length(cSplit[0]));
@ -205,7 +172,7 @@ export default class Event {
this.Content = new TextDecoder().decode(result); this.Content = new TextDecoder().decode(result);
} }
async _GetDmSharedKey(pubkey, privkey) { async _GetDmSharedKey(pubkey: HexKey, privkey: HexKey) {
let sharedPoint = secp.getSharedSecret(privkey, '02' + pubkey); let sharedPoint = secp.getSharedSecret(privkey, '02' + pubkey);
let sharedX = sharedPoint.slice(1, 33); let sharedX = sharedPoint.slice(1, 33);
return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"]) return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"])

View File

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

13
src/nostr/EventKind.ts Normal file
View File

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

View File

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

139
src/nostr/Subscriptions.ts Normal file
View File

@ -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<u256> | null
/**
* a list of pubkeys or prefixes, the pubkey of an event must be one of these
*/
Authors: Set<u256> | null;
/**
* a list of a kind numbers
*/
Kinds: Set<EventKind> | null;
/**
* a list of event ids that are referenced in an "e" tag
*/
ETags: Set<u256> | null;
/**
* a list of pubkeys that are referenced in a "p" tag
*/
PTags: Set<u256> | 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<Subscriptions>;
/**
* Start time for this subscription
*/
Started: Map<string, number>;
/**
* End time for this subscription
*/
Finished: Map<string, number>;
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<string, number>();
this.Finished = new Map<string, number>();
}
/**
* 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;
}
}

View File

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

124
src/nostr/System.ts Normal file
View File

@ -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<string, Connection>;
/**
* All active subscriptions
*/
Subscriptions: Map<string, Subscriptions>;
/**
* 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();

View File

@ -1,11 +1,18 @@
import { HexKey, RawReqFilter, u256 } from ".";
export default class Tag { 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.Key = tag[0];
this.Event = null;
this.PubKey = null;
this.Relay = null;
this.Marker = null;
this.Other = null;
this.Index = index; this.Index = index;
this.Invalid = false; this.Invalid = false;
@ -13,8 +20,8 @@ export default class Tag {
case "e": { case "e": {
// ["e", <event-id>, <relay-url>, <marker>] // ["e", <event-id>, <relay-url>, <marker>]
this.Event = tag[1]; this.Event = tag[1];
this.Relay = tag.length > 2 ? tag[2] : null; this.Relay = tag.length > 2 ? tag[2] : undefined;
this.Marker = tag.length > 3 ? tag[3] : null; this.Marker = tag.length > 3 ? tag[3] : undefined;
if (!this.Event) { if (!this.Event) {
this.Invalid = true; this.Invalid = true;
} }
@ -32,27 +39,23 @@ export default class Tag {
this.PubKey = tag[1]; this.PubKey = tag[1];
break; break;
} }
default: {
this.Other = tag;
break;
}
} }
} }
ToObject() { ToObject(): string[] | null {
switch (this.Key) { switch (this.Key) {
case "e": { case "e": {
let ret = ["e", this.Event, this.Relay, this.Marker]; let ret = ["e", this.Event, this.Relay, this.Marker];
let trimEnd = ret.reverse().findIndex(a => a != null); let trimEnd = ret.reverse().findIndex(a => a !== undefined);
return ret.reverse().slice(0, ret.length - trimEnd); ret = ret.reverse().slice(0, ret.length - trimEnd);
return <string[]>ret;
} }
case "p": { case "p": {
return ["p", this.PubKey]; return this.PubKey ? ["p", this.PubKey] : null;
} }
default: { default: {
return this.Other; return this.Original;
} }
} }
return null;
} }
} }

View File

@ -1,23 +1,24 @@
import Event from "./Event"; import { u256 } from ".";
import { default as NEvent } from "./Event";
import EventKind from "./EventKind"; import EventKind from "./EventKind";
import Tag from "./Tag";
export default class Thread { export default class Thread {
Root?: Tag;
ReplyTo?: Tag;
Mentions: Array<Tag>;
PubKeys: Array<u256>;
constructor() { constructor() {
/** @type {Tag} */
this.Root = null;
/** @type {Tag} */
this.ReplyTo = null;
/** @type {Array<Tag>} */
this.Mentions = []; this.Mentions = [];
/** @type {Array<String>} */
this.PubKeys = []; this.PubKeys = [];
} }
/** /**
* Extract thread information from an Event * 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"); let isThread = ev.Tags.some(a => a.Key === "e");
if (!isThread) { if (!isThread) {
return null; return null;
@ -26,13 +27,13 @@ export default class Thread {
let shouldWriteMarkers = ev.Kind === EventKind.TextNote; let shouldWriteMarkers = ev.Kind === EventKind.TextNote;
let ret = new Thread(); let ret = new Thread();
let eTags = ev.Tags.filter(a => a.Key === "e"); 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) { if (!marked) {
ret.Root = eTags[0]; ret.Root = eTags[0];
ret.Root.Marker = shouldWriteMarkers ? "root" : null; ret.Root.Marker = shouldWriteMarkers ? "root" : undefined;
if (eTags.length > 1) { if (eTags.length > 1) {
ret.ReplyTo = eTags[1]; ret.ReplyTo = eTags[1];
ret.ReplyTo.Marker = shouldWriteMarkers ? "reply" : null; ret.ReplyTo.Marker = shouldWriteMarkers ? "reply" : undefined;
} }
if (eTags.length > 2) { if (eTags.length > 2) {
ret.Mentions = eTags.slice(2); ret.Mentions = eTags.slice(2);
@ -47,7 +48,7 @@ export default class Thread {
ret.ReplyTo = reply; ret.ReplyTo = reply;
ret.Mentions = eTags.filter(a => a.Marker === "mention"); 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 => <u256>a.PubKey)));
return ret; return ret;
} }
} }

View File

@ -1,9 +1,55 @@
export interface RawEvent { export type RawEvent = {
id: string, id: u256,
pubkey: string, pubkey: HexKey,
created_at: number, created_at: number,
kind: number, kind: number,
tags: string[][], tags: string[][],
content: string, content: string,
sig: 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
} }

View File

@ -5,7 +5,7 @@ import { Outlet, useNavigate } from "react-router-dom";
import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons"; import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { System } from ".." import { System } from "../nostr/System"
import ProfileImage from "../element/ProfileImage"; import ProfileImage from "../element/ProfileImage";
import { init } from "../state/Login"; import { init } from "../state/Login";
import useLoginFeed from "../feed/LoginFeed"; import useLoginFeed from "../feed/LoginFeed";

View File

@ -19,7 +19,7 @@ export default function NotificationsPage() {
const etagged = useMemo(() => { const etagged = useMemo(() => {
return notifications?.filter(a => a.kind === EventKind.Reaction) return notifications?.filter(a => a.kind === EventKind.Reaction)
.map(a => { .map(a => {
let ev = Event.FromObject(a); let ev = new Event(a);
return ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; return ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
}) })
}, [notifications]); }, [notifications]);
@ -27,12 +27,12 @@ export default function NotificationsPage() {
const subEvents = useMemo(() => { const subEvents = useMemo(() => {
let sub = new Subscriptions(); let sub = new Subscriptions();
sub.Id = `reactions:${sub.Id}`; 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)); sub.ETags = new Set(notifications?.filter(b => b.kind === EventKind.TextNote).map(b => b.id));
if (etagged.length > 0) { if (etagged.length > 0) {
let reactionsTo = new Subscriptions(); let reactionsTo = new Subscriptions();
reactionsTo.Kinds.add(EventKind.TextNote); reactionsTo.Kinds = new Set([EventKind.TextNote]);
reactionsTo.Ids = new Set(etagged); reactionsTo.Ids = new Set(etagged);
sub.OrSubs.push(reactionsTo); 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)); let reactions = otherNotes?.notes?.filter(c => c.tags.find(b => b[0] === "e" && b[1] === a.id));
return <Note data={a} key={a.id} reactions={reactions} /> return <Note data={a} key={a.id} reactions={reactions} />
} else if (a.kind === EventKind.Reaction) { } 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 reactedTo = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
let reactedNote = otherNotes?.notes?.find(c => c.id === reactedTo); let reactedNote = otherNotes?.notes?.find(c => c.id === reactedTo);
return <NoteReaction data={a} key={a.id} root={reactedNote} /> return <NoteReaction data={a} key={a.id} root={reactedNote} />

View File

@ -105,57 +105,54 @@ export default function ProfilePage() {
} }
function avatar() { function avatar() {
return ( return (
<div className="avatar-wrapper"> <div className="avatar-wrapper">
<div style={{ '--img-url': backgroundImage }} className="avatar" data-domain={domain?.toLowerCase()}> <div style={{ '--img-url': backgroundImage }} className="avatar" data-domain={isVerified ? domain : ''}>
</div> </div>
</div> </div>
) )
} }
function userDetails() { function userDetails() {
return ( return (
<div className="details-wrapper"> <div className="details-wrapper">
{username()} {username()}
{isMe ? ( {isMe ? (
<div className="btn btn-icon follow-button" onClick={() => navigate("/settings")}> <div className="btn btn-icon follow-button" onClick={() => navigate("/settings")}>
<FontAwesomeIcon icon={faGear} size="lg" /> <FontAwesomeIcon icon={faGear} size="lg" />
</div> </div>
) : <> ) : <>
<div className="btn message-button" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}> <div className="btn message-button" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
<FontAwesomeIcon icon={faEnvelope} size="lg" /> <FontAwesomeIcon icon={faEnvelope} size="lg" />
</div> </div>
<FollowButton pubkey={id} /> <FollowButton pubkey={id} />
</> </>
} }
{bio()} {bio()}
</div> </div>
) )
} }
return ( return (
<> <>
<div className="profile flex"> <div className="profile flex">
{user?.banner && <img alt="banner" className="banner" src={user.banner} /> } {user?.banner && <img alt="banner" className="banner" src={user.banner} />}
{user?.banner ? ( {user?.banner ? (
<> <>
{avatar()} {avatar()}
{userDetails()} {userDetails()}
</> </>
) : ( ) : (
<div className="no-banner"> <div className="no-banner">
{avatar()} {avatar()}
{userDetails()} {userDetails()}
</div> </div>
)} )}
</div> </div>
<div className="tabs"> <div className="tabs">
{ {Object.entries(ProfileTab).map(([k, v]) => {
Object.entries(ProfileTab).map(([k, v]) => { return <div className={`tab f-1${tab === v ? " active" : ""}`} key={k} onClick={() => setTab(v)}>{k}</div>
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={k} onClick={() => setTab(v)}>{k}</div> })}
}
)
}
</div> </div>
{tabContent()} {tabContent()}
</> </>

View File

@ -1,71 +1,87 @@
import { createSlice } from '@reduxjs/toolkit' import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import * as secp from '@noble/secp256k1'; import * as secp from '@noble/secp256k1';
import { DefaultRelays } from '../Const'; import { DefaultRelays } from '../Const';
import { HexKey, RawEvent, TaggedRawEvent } from '../nostr';
import { RelaySettings } from '../nostr/Connection';
const PrivateKeyItem = "secret"; const PrivateKeyItem = "secret";
const PublicKeyItem = "pubkey"; const PublicKeyItem = "pubkey";
const NotificationsReadItem = "notifications-read"; 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({ const LoginSlice = createSlice({
name: "Login", name: "Login",
initialState: { initialState: <LoginStore>{
/**
* If there is no login
*/
loggedOut: null,
/**
* Current user private key
*/
privateKey: null,
/**
* Current users public key
*/
publicKey: null,
/**
* Configured relays for this user
*/
relays: {}, relays: {},
latestRelays: 0,
/**
* Newest relay list timestamp
*/
latestRelays: null,
/**
* A list of pubkeys this user follows
*/
follows: [], follows: [],
/**
* Notifications for this login session
*/
notifications: [], notifications: [],
/**
* Timestamp of last read notification
*/
readNotifications: 0, readNotifications: 0,
/**
* Encrypted DM's
*/
dms: [] dms: []
}, },
reducers: { reducers: {
init: (state) => { init: (state) => {
state.privateKey = window.localStorage.getItem(PrivateKeyItem); state.privateKey = window.localStorage.getItem(PrivateKeyItem) ?? undefined;
if (state.privateKey) { if (state.privateKey) {
window.localStorage.removeItem(PublicKeyItem); // reset nip07 if using private key 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; state.loggedOut = false;
} else { } else {
state.loggedOut = true; state.loggedOut = true;
} }
state.relays = DefaultRelays; state.relays = Object.fromEntries(DefaultRelays.entries());
// check pub key only // check pub key only
let pubKey = window.localStorage.getItem(PublicKeyItem); let pubKey = window.localStorage.getItem(PublicKeyItem);
@ -75,41 +91,45 @@ const LoginSlice = createSlice({
} }
// notifications // notifications
let readNotif = parseInt(window.localStorage.getItem(NotificationsReadItem)); let readNotif = parseInt(window.localStorage.getItem(NotificationsReadItem) ?? "0");
if (!isNaN(readNotif)) { if (!isNaN(readNotif)) {
state.readNotifications = readNotif; state.readNotifications = readNotif;
} }
}, },
setPrivateKey: (state, action) => { setPrivateKey: (state, action: PayloadAction<HexKey>) => {
state.loggedOut = false; state.loggedOut = false;
state.privateKey = action.payload; state.privateKey = action.payload;
window.localStorage.setItem(PrivateKeyItem, 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<HexKey>) => {
window.localStorage.setItem(PublicKeyItem, action.payload); window.localStorage.setItem(PublicKeyItem, action.payload);
state.loggedOut = false; state.loggedOut = false;
state.publicKey = action.payload; state.publicKey = action.payload;
}, },
setRelays: (state, action) => { setRelays: (state, action: PayloadAction<SetRelaysPayload>) => {
let relays = action.payload.relays; let relays = action.payload.relays;
let createdAt = action.payload.createdAt; let createdAt = action.payload.createdAt;
if(state.latestRelays > createdAt) { if (state.latestRelays > createdAt) {
return; return;
} }
// filter out non-websocket urls // filter out non-websocket urls
let filtered = Object.entries(relays) let filtered = new Map<string, RelaySettings>();
.filter(a => a[0].startsWith("ws://") || a[0].startsWith("wss://")); for (let [k, v] of Object.entries(relays)) {
if (k.startsWith("wss://") || k.startsWith("ws://")) {
filtered.set(k, <RelaySettings>v);
}
}
state.relays = Object.fromEntries(filtered); state.relays = Object.fromEntries(filtered.entries());
state.latestRelays = createdAt; state.latestRelays = createdAt;
}, },
removeRelay: (state, action) => { removeRelay: (state, action: PayloadAction<string>) => {
delete state.relays[action.payload]; delete state.relays[action.payload];
state.relays = { ...state.relays }; state.relays = { ...state.relays };
}, },
setFollows: (state, action) => { setFollows: (state, action: PayloadAction<string | string[]>) => {
let existing = new Set(state.follows); let existing = new Set(state.follows);
let update = Array.isArray(action.payload) ? action.payload : [action.payload]; let update = Array.isArray(action.payload) ? action.payload : [action.payload];
@ -124,7 +144,7 @@ const LoginSlice = createSlice({
state.follows = Array.from(existing); state.follows = Array.from(existing);
} }
}, },
addNotifications: (state, action) => { addNotifications: (state, action: PayloadAction<TaggedRawEvent | TaggedRawEvent[]>) => {
let n = action.payload; let n = action.payload;
if (!Array.isArray(n)) { if (!Array.isArray(n)) {
n = [n]; n = [n];
@ -143,7 +163,7 @@ const LoginSlice = createSlice({
]; ];
} }
}, },
addDirectMessage: (state, action) => { addDirectMessage: (state, action: PayloadAction<TaggedRawEvent | Array<TaggedRawEvent>>) => {
let n = action.payload; let n = action.payload;
if (!Array.isArray(n)) { if (!Array.isArray(n)) {
n = [n]; n = [n];
@ -166,8 +186,8 @@ const LoginSlice = createSlice({
window.localStorage.removeItem(PrivateKeyItem); window.localStorage.removeItem(PrivateKeyItem);
window.localStorage.removeItem(PublicKeyItem); window.localStorage.removeItem(PublicKeyItem);
window.localStorage.removeItem(NotificationsReadItem); window.localStorage.removeItem(NotificationsReadItem);
state.privateKey = null; state.privateKey = undefined;
state.publicKey = null; state.publicKey = undefined;
state.follows = []; state.follows = [];
state.notifications = []; state.notifications = [];
state.loggedOut = true; state.loggedOut = true;
@ -176,10 +196,21 @@ const LoginSlice = createSlice({
}, },
markNotificationsRead: (state) => { markNotificationsRead: (state) => {
state.readNotifications = new Date().getTime(); 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; export const reducer = LoginSlice.reducer;

View File

@ -2,11 +2,14 @@ import { configureStore } from "@reduxjs/toolkit";
import { reducer as UsersReducer } from "./Users"; import { reducer as UsersReducer } from "./Users";
import { reducer as LoginReducer } from "./Login"; import { reducer as LoginReducer } from "./Login";
const Store = configureStore({ const store = configureStore({
reducer: { reducer: {
users: UsersReducer, users: UsersReducer,
login: LoginReducer login: LoginReducer
} }
}); });
export default Store; export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export default store;

View File

@ -1,22 +1,38 @@
import { createSlice } from '@reduxjs/toolkit' import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { ProfileCacheExpire } from '../Const'; import { ProfileCacheExpire } from '../Const';
import { db } from '../db'; 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({ const UsersSlice = createSlice({
name: "Users", name: "Users",
initialState: { initialState: <UsersStore>{
/**
* Set of known pubkeys
*/
pubKeys: [], pubKeys: [],
/**
* User objects for known pubKeys, populated async
*/
users: {}, users: {},
}, },
reducers: { reducers: {
addPubKey: (state, action) => { addPubKey: (state, action: PayloadAction<string | Array<string>>) => {
let keys = action.payload; let keys = action.payload;
if (!Array.isArray(keys)) { if (!Array.isArray(keys)) {
keys = [keys]; keys = [keys];
@ -32,7 +48,7 @@ const UsersSlice = createSlice({
// load from cache // load from cache
let cache = window.localStorage.getItem(`user:${k}`); let cache = window.localStorage.getItem(`user:${k}`);
if (cache) { if (cache) {
let ud = JSON.parse(cache); let ud: MetadataCache = JSON.parse(cache);
if (ud.loaded > new Date().getTime() - ProfileCacheExpire) { if (ud.loaded > new Date().getTime() - ProfileCacheExpire) {
state.users[ud.pubkey] = ud; state.users[ud.pubkey] = ud;
fromCache = true; fromCache = true;
@ -49,7 +65,7 @@ const UsersSlice = createSlice({
} }
} }
}, },
setUserData: (state, action) => { setUserData: (state, action: PayloadAction<MetadataCache | Array<MetadataCache>>) => {
let ud = action.payload; let ud = action.payload;
if (!Array.isArray(ud)) { if (!Array.isArray(ud)) {
ud = [ud]; ud = [ud];
@ -58,7 +74,7 @@ const UsersSlice = createSlice({
for (let x of ud) { for (let x of ud) {
let existing = state.users[x.pubkey]; let existing = state.users[x.pubkey];
if (existing) { if (existing) {
if (existing.fromEvent.created_at > x.fromEvent.created_at) { if (existing.created > x.created) {
// prevent patching with older metadata // prevent patching with older metadata
continue; continue;
} }
@ -82,8 +98,8 @@ const UsersSlice = createSlice({
}; };
} }
}, },
resetProfile: (state, action) => { resetProfile: (state, action: PayloadAction<HexKey>) => {
if (state.users[action.payload]) { if (action.payload in state.users) {
delete state.users[action.payload]; delete state.users[action.payload];
state.users = { state.users = {
...state.users ...state.users

View File

@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es6",
"jsx": "react-jsx", "jsx": "react-jsx",
"moduleResolution": "node", "moduleResolution": "node",
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,

View File

@ -2127,6 +2127,10 @@
integrity sha512-e1DZGD+eH19BnllTWCGXAdrMa2kI53wEMuhn/d+wUmnu8//ZI6BiuK/EPdw07fI4+tlyo5qdPZdXdpkoXHJVOw== integrity sha512-e1DZGD+eH19BnllTWCGXAdrMa2kI53wEMuhn/d+wUmnu8//ZI6BiuK/EPdw07fI4+tlyo5qdPZdXdpkoXHJVOw==
dependencies: dependencies:
"@types/react" "*" "@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": "@types/ws@^8.5.1":
version "8.5.4" version "8.5.4"