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/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",

View File

@ -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<string, RelaySettings>([
["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

View File

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

View File

@ -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 || "<ERROR>");
}
useEffect(() => {

View File

@ -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 (
<>
<h4>Unknown event kind: {ev.Kind}</h4>

View File

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

View File

@ -10,7 +10,7 @@ export default function Thread(props) {
const thisEvent = props.this;
/** @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
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.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 { 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]);

View File

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

View File

@ -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<RootState, [HexKey | undefined, number]>(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]);
}

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 { 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<TaggedRawEvent>
};
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, <NoteStore>{ 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()) {

View File

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

View File

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

View File

@ -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<RootState, HexKey[]>(s => s.users.pubKeys);
const users = useSelector<RootState, any>(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
};

View File

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

View File

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

View File

@ -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<StateSnapshot>) => 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<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.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 = <StateSnapshot>{
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<any>) {
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<Subscriptions>} 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)) {

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 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<Tag>}
*/
this.Tags = [];
/**
* A list of metadata tags
*/
Tags: Array<Tag>;
/**
* 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: <string[][]>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"])

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 {
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", <event-id>, <relay-url>, <marker>]
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 <string[]>ret;
}
case "p": {
return ["p", this.PubKey];
return this.PubKey ? ["p", this.PubKey] : null;
}
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 Tag from "./Tag";
export default class Thread {
Root?: Tag;
ReplyTo?: Tag;
Mentions: Array<Tag>;
PubKeys: Array<u256>;
constructor() {
/** @type {Tag} */
this.Root = null;
/** @type {Tag} */
this.ReplyTo = null;
/** @type {Array<Tag>} */
this.Mentions = [];
/** @type {Array<String>} */
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 => <u256>a.PubKey)));
return ret;
}
}

View File

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

View File

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

View File

@ -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 <Note data={a} key={a.id} reactions={reactions} />
} 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 <NoteReaction data={a} key={a.id} root={reactedNote} />

View File

@ -105,57 +105,54 @@ export default function ProfilePage() {
}
function avatar() {
return (
<div className="avatar-wrapper">
<div style={{ '--img-url': backgroundImage }} className="avatar" data-domain={domain?.toLowerCase()}>
</div>
</div>
)
return (
<div className="avatar-wrapper">
<div style={{ '--img-url': backgroundImage }} className="avatar" data-domain={isVerified ? domain : ''}>
</div>
</div>
)
}
function userDetails() {
return (
<div className="details-wrapper">
{username()}
{isMe ? (
<div className="btn btn-icon follow-button" onClick={() => navigate("/settings")}>
<FontAwesomeIcon icon={faGear} size="lg" />
</div>
) : <>
<div className="btn message-button" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
<FontAwesomeIcon icon={faEnvelope} size="lg" />
</div>
<FollowButton pubkey={id} />
</>
}
{bio()}
</div>
)
return (
<div className="details-wrapper">
{username()}
{isMe ? (
<div className="btn btn-icon follow-button" onClick={() => navigate("/settings")}>
<FontAwesomeIcon icon={faGear} size="lg" />
</div>
) : <>
<div className="btn message-button" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
<FontAwesomeIcon icon={faEnvelope} size="lg" />
</div>
<FollowButton pubkey={id} />
</>
}
{bio()}
</div>
)
}
return (
<>
<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 ? (
<>
{avatar()}
{userDetails()}
</>
<>
{avatar()}
{userDetails()}
</>
) : (
<div className="no-banner">
{avatar()}
{userDetails()}
</div>
<div className="no-banner">
{avatar()}
{userDetails()}
</div>
)}
</div>
<div className="tabs">
{
Object.entries(ProfileTab).map(([k, v]) => {
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={k} onClick={() => setTab(v)}>{k}</div>
}
)
}
{Object.entries(ProfileTab).map(([k, v]) => {
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={k} onClick={() => setTab(v)}>{k}</div>
})}
</div>
{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 { 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: <LoginStore>{
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<HexKey>) => {
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<HexKey>) => {
window.localStorage.setItem(PublicKeyItem, action.payload);
state.loggedOut = false;
state.publicKey = action.payload;
},
setRelays: (state, action) => {
setRelays: (state, action: PayloadAction<SetRelaysPayload>) => {
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<string, RelaySettings>();
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;
},
removeRelay: (state, action) => {
removeRelay: (state, action: PayloadAction<string>) => {
delete state.relays[action.payload];
state.relays = { ...state.relays };
},
setFollows: (state, action) => {
setFollows: (state, action: PayloadAction<string | string[]>) => {
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<TaggedRawEvent | TaggedRawEvent[]>) => {
let n = action.payload;
if (!Array.isArray(n)) {
n = [n];
@ -143,7 +163,7 @@ const LoginSlice = createSlice({
];
}
},
addDirectMessage: (state, action) => {
addDirectMessage: (state, action: PayloadAction<TaggedRawEvent | Array<TaggedRawEvent>>) => {
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;

View File

@ -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;
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 { 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: <UsersStore>{
pubKeys: [],
/**
* User objects for known pubKeys, populated async
*/
users: {},
},
reducers: {
addPubKey: (state, action) => {
addPubKey: (state, action: PayloadAction<string | Array<string>>) => {
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<MetadataCache | Array<MetadataCache>>) => {
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<HexKey>) => {
if (action.payload in state.users) {
delete state.users[action.payload];
state.users = {
...state.users

View File

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

View File

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