refactor: TS
This commit is contained in:
parent
9cd24e5623
commit
c7e42c1f75
@ -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",
|
||||
|
@ -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
|
@ -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);
|
@ -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(() => {
|
||||
|
@ -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>
|
||||
|
@ -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) {
|
||||
|
@ -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]);
|
||||
|
@ -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} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
259
src/feed/EventPublisher.ts
Normal 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;
|
||||
}
|
||||
};
|
@ -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]);
|
@ -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]);
|
@ -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]);
|
||||
}
|
@ -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
18
src/feed/ProfileFeed.ts
Normal 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;
|
||||
}
|
@ -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
11
src/feed/RelayState.ts
Normal 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);
|
||||
}
|
@ -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()) {
|
@ -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 });
|
||||
|
@ -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;
|
@ -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
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
||||
*/
|
||||
|
@ -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)) {
|
44
src/nostr/ConnectionStats.ts
Normal file
44
src/nostr/ConnectionStats.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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"])
|
@ -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
13
src/nostr/EventKind.ts
Normal 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;
|
@ -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
139
src/nostr/Subscriptions.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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
124
src/nostr/System.ts
Normal 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();
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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";
|
||||
|
@ -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} />
|
||||
|
@ -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()}
|
||||
</>
|
||||
|
@ -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;
|
@ -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;
|
@ -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
|
@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user