refactor: TS #69

Merged
v0l merged 5 commits from ts-core into main 2023-01-16 13:23:56 +00:00
54 changed files with 1276 additions and 1169 deletions

View File

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

View File

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

View File

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

32
src/db/User.ts Normal file
View File

@ -0,0 +1,32 @@
import { HexKey, TaggedRawEvent, 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 function mapEventToProfile(ev: TaggedRawEvent) {
try {
let data: UserMetadata = JSON.parse(ev.content);
return {
pubkey: ev.pubkey,
created: ev.created_at,
loaded: new Date().getTime(),
...data
} as MetadataCache;
} catch (e) {
console.error("Failed to parse JSON", ev, e);
}
}

View File

@ -1,9 +1,9 @@
import Dexie, { Table } from 'dexie'; import Dexie, { Table } from 'dexie';
import { MetadataCache } from './User';
import type { User } from './nostr/types';
export class SnortDB extends Dexie { export class SnortDB extends Dexie {
users!: Table<User>; users!: Table<MetadataCache>;
constructor() { constructor() {
super('snortDB'); super('snortDB');

View File

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

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
ServiceProvider, ServiceProvider,
@ -15,14 +15,10 @@ import AsyncButton from "./AsyncButton";
import LNURLTip from "./LNURLTip"; import LNURLTip from "./LNURLTip";
// @ts-ignore // @ts-ignore
import Copy from "./Copy"; import Copy from "./Copy";
// @ts-ignore
import useProfile from "../feed/ProfileFeed"; import useProfile from "../feed/ProfileFeed";
// @ts-ignore
import useEventPublisher from "../feed/EventPublisher"; import useEventPublisher from "../feed/EventPublisher";
// @ts-ignore
import { resetProfile } from "../state/Users";
// @ts-ignore
import { hexToBech32 } from "../Util"; import { hexToBech32 } from "../Util";
import { UserMetadata } from "../nostr";
type Nip05ServiceProps = { type Nip05ServiceProps = {
name: string, name: string,
@ -35,10 +31,9 @@ type Nip05ServiceProps = {
type ReduxStore = any; type ReduxStore = any;
export default function Nip5Service(props: Nip05ServiceProps) { export default function Nip5Service(props: Nip05ServiceProps) {
const dispatch = useDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey); const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey);
const user: any = useProfile(pubkey); const user = useProfile(pubkey);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const svc = new ServiceProvider(props.service); const svc = new ServiceProvider(props.service);
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>(); const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
@ -71,11 +66,11 @@ export default function Nip5Service(props: Nip05ServiceProps) {
setError(undefined); setError(undefined);
setAvailabilityResponse(undefined); setAvailabilityResponse(undefined);
if (handle && domain) { if (handle && domain) {
if(handle.length < (domainConfig?.length[0] ?? 2)) { if (handle.length < (domainConfig?.length[0] ?? 2)) {
setAvailabilityResponse({ available: false, why: "TOO_SHORT" }); setAvailabilityResponse({ available: false, why: "TOO_SHORT" });
return; return;
} }
if(handle.length > (domainConfig?.length[1] ?? 20)) { if (handle.length > (domainConfig?.length[1] ?? 20)) {
setAvailabilityResponse({ available: false, why: "TOO_LONG" }); setAvailabilityResponse({ available: false, why: "TOO_LONG" });
return; return;
} }
@ -149,18 +144,16 @@ export default function Nip5Service(props: Nip05ServiceProps) {
} }
async function updateProfile(handle: string, domain: string) { async function updateProfile(handle: string, domain: string) {
if (user) {
let newProfile = { let newProfile = {
...user, ...user,
nip05: `${handle}@${domain}` nip05: `${handle}@${domain}`
}; } as UserMetadata;
delete newProfile["loaded"];
delete newProfile["fromEvent"];
delete newProfile["pubkey"];
let ev = await publisher.metadata(newProfile); let ev = await publisher.metadata(newProfile);
dispatch(resetProfile(pubkey));
publisher.broadcast(ev); publisher.broadcast(ev);
navigate("/settings"); navigate("/settings");
} }
}
return ( return (
<> <>

View File

@ -1,6 +1,5 @@
import "./Note.css"; import "./Note.css";
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Event from "../nostr/Event"; import Event from "../nostr/Event";
@ -9,15 +8,16 @@ import Text from "./Text";
import { eventLink, hexToBech32 } from "../Util"; import { eventLink, hexToBech32 } from "../Util";
import NoteFooter from "./NoteFooter"; import NoteFooter from "./NoteFooter";
import NoteTime from "./NoteTime"; import NoteTime from "./NoteTime";
import EventKind from "../nostr/EventKind";
import useProfile from "../feed/ProfileFeed";
export default function Note(props) { export default function Note(props) {
const navigate = useNavigate(); const navigate = useNavigate();
const opt = props.options; const { data, isThread, reactions, deletion, hightlight, options: opt, ["data-ev"]: parsedEvent } = props
const dataEvent = props["data-ev"]; const ev = useMemo(() => parsedEvent ?? new Event(data), [data]);
const { data, isThread, reactions, deletion, hightlight } = props const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
const users = useSelector(s => s.users?.users); const users = useProfile(pubKeys);
const ev = dataEvent ?? Event.FromObject(data);
const options = { const options = {
showHeader: true, showHeader: true,
@ -31,8 +31,8 @@ export default function Note(props) {
if (deletion?.length > 0) { if (deletion?.length > 0) {
return (<b className="error">Deleted</b>); return (<b className="error">Deleted</b>);
} }
return <Text content={body} tags={ev.Tags} users={users} />; return <Text content={body} tags={ev.Tags} users={users || []} />;
}, [data, dataEvent, reactions, deletion]); }, [props]);
function goToEvent(e, id) { function goToEvent(e, id) {
if (!window.location.pathname.startsWith("/e/")) { if (!window.location.pathname.startsWith("/e/")) {
@ -48,7 +48,7 @@ export default function Note(props) {
const maxMentions = 2; const maxMentions = 2;
let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
let mentions = ev.Thread?.PubKeys?.map(a => [a, users[a]])?.map(a => (a[1]?.name?.length ?? 0) > 0 ? a[1].name : hexToBech32("npub", a[0]).substring(0, 12)) let mentions = ev.Thread?.PubKeys?.map(a => [a, users ? users[a] : null])?.map(a => (a[1]?.name?.length ?? 0) > 0 ? a[1].name : hexToBech32("npub", a[0]).substring(0, 12))
.sort((a, b) => a.startsWith("npub") ? 1 : -1); .sort((a, b) => a.startsWith("npub") ? 1 : -1);
let othersLength = mentions.length - maxMentions let othersLength = mentions.length - maxMentions
let pubMentions = mentions.length > maxMentions ? `${mentions?.slice(0, maxMentions).join(", ")} & ${othersLength} other${othersLength > 1 ? 's' : ''}` : mentions?.join(", "); let pubMentions = mentions.length > maxMentions ? `${mentions?.slice(0, maxMentions).join(", ")} & ${othersLength} other${othersLength > 1 ? 's' : ''}` : mentions?.join(", ");
@ -59,7 +59,7 @@ export default function Note(props) {
) )
} }
if (!ev.IsContent()) { if (ev.Kind !== EventKind.TextNote) {
return ( return (
<> <>
<h4>Unknown event kind: {ev.Kind}</h4> <h4>Unknown event kind: {ev.Kind}</h4>

View File

@ -1,5 +1,4 @@
import { useState, Component } from "react"; import { useState } from "react";
import { useSelector } from "react-redux";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPaperclip } from "@fortawesome/free-solid-svg-icons"; import { faPaperclip } from "@fortawesome/free-solid-svg-icons";
@ -20,7 +19,6 @@ export function NoteCreator(props) {
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const users = useSelector((state) => state.users.users)
async function sendNote() { async function sendNote() {
let ev = replyTo ? await publisher.reply(replyTo, note) : await publisher.note(note); let ev = replyTo ? await publisher.reply(replyTo, note) : await publisher.note(note);
@ -71,7 +69,6 @@ export function NoteCreator(props) {
<Textarea <Textarea
autoFocus={autoFocus} autoFocus={autoFocus}
className={`textarea ${active ? "textarea--focused" : ""}`} className={`textarea ${active ? "textarea--focused" : ""}`}
users={users}
onChange={onChange} onChange={onChange}
value={note} value={note}
onFocus={() => setActive(true)} onFocus={() => setActive(true)}

View File

@ -7,13 +7,14 @@ import useEventPublisher from "../feed/EventPublisher";
import { normalizeReaction, Reaction } from "../Util"; import { normalizeReaction, Reaction } from "../Util";
import { NoteCreator } from "./NoteCreator"; import { NoteCreator } from "./NoteCreator";
import LNURLTip from "./LNURLTip"; import LNURLTip from "./LNURLTip";
import useProfile from "../feed/ProfileFeed";
export default function NoteFooter(props) { export default function NoteFooter(props) {
const reactions = props.reactions; const reactions = props.reactions;
const ev = props.ev; const ev = props.ev;
const login = useSelector(s => s.login.publicKey); const login = useSelector(s => s.login.publicKey);
const author = useSelector(s => s.users.users[ev.RootPubKey]); const author = useProfile(ev.RootPubKey);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const [reply, setReply] = useState(false); const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false); const [tip, setTip] = useState(false);

View File

@ -9,7 +9,7 @@ import { useMemo } from "react";
import NoteTime from "./NoteTime"; import NoteTime from "./NoteTime";
export default function NoteReaction(props) { export default function NoteReaction(props) {
const ev = props["data-ev"] || Event.FromObject(props.data); const ev = props["data-ev"] || new Event(props.data);
const refEvent = useMemo(() => { const refEvent = useMemo(() => {
if (ev) { if (ev) {

View File

@ -1,4 +1,3 @@
import { useSelector } from "react-redux";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete"; import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
@ -10,14 +9,13 @@ import "@webscopeio/react-textarea-autocomplete/style.css";
import "./Textarea.css"; import "./Textarea.css";
// @ts-expect-error // @ts-expect-error
import Nostrich from "../nostrich.jpg"; import Nostrich from "../nostrich.jpg";
// @ts-expect-error
import { hexToBech32 } from "../Util"; import { hexToBech32 } from "../Util";
import type { User } from "../nostr/types";
import { db } from "../db"; import { db } from "../db";
import { MetadataCache } from "../db/User";
function searchUsers(query: string, users: User[]) { function searchUsers(query: string, users: MetadataCache[]) {
const q = query.toLowerCase() const q = query.toLowerCase()
return users.filter(({ name, display_name, about, nip05 }: User) => { return users.filter(({ name, display_name, about, nip05 }: MetadataCache) => {
return name?.toLowerCase().includes(q) return name?.toLowerCase().includes(q)
|| display_name?.toLowerCase().includes(q) || display_name?.toLowerCase().includes(q)
|| about?.toLowerCase().includes(q) || about?.toLowerCase().includes(q)
@ -25,7 +23,7 @@ function searchUsers(query: string, users: User[]) {
}).slice(0, 3) }).slice(0, 3)
} }
const UserItem = ({ pubkey, display_name, picture, nip05, ...rest }: User) => { const UserItem = ({ pubkey, display_name, picture, nip05, ...rest }: MetadataCache) => {
return ( return (
<div key={pubkey} className="user-item"> <div key={pubkey} className="user-item">
<div className="user-picture"> <div className="user-picture">
@ -39,22 +37,10 @@ const UserItem = ({ pubkey, display_name, picture, nip05, ...rest }: User) => {
) )
} }
function normalizeUser({ pubkey, picture, nip05, name, display_name }: User) {
return { pubkey, nip05, name, picture, display_name }
}
const Textarea = ({ users, onChange, ...rest }: any) => { const Textarea = ({ users, onChange, ...rest }: any) => {
const normalizedUsers = Object.keys(users).reduce((acc, pk) => { const allUsers = useLiveQuery(
return {...acc, [pk]: normalizeUser(users[pk]) } () => db.users.toArray()
}, {}) );
const dbUsers = useLiveQuery(
() => db.users.toArray().then(usrs => {
return usrs.reduce((acc, usr) => {
return { ...acc, [usr.pubkey]: normalizeUser(usr)}
}, {})
})
)
const allUsers: User[] = Object.values({...normalizedUsers, ...dbUsers})
return ( return (
<ReactTextareaAutocomplete <ReactTextareaAutocomplete
@ -66,7 +52,7 @@ const Textarea = ({ users, onChange, ...rest }: any) => {
trigger={{ trigger={{
"@": { "@": {
afterWhitespace: true, afterWhitespace: true,
dataProvider: token => dbUsers ? searchUsers(token, allUsers) : [], dataProvider: token => allUsers ? searchUsers(token, allUsers) : [],
component: (props: any) => <UserItem {...props.entity} />, component: (props: any) => <UserItem {...props.entity} />,
output: (item: any) => `@${hexToBech32("npub", item.pubkey)}` output: (item: any) => `@${hexToBech32("npub", item.pubkey)}`
} }

View File

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

View File

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

View File

@ -1,233 +0,0 @@
import { useSelector } from "react-redux";
import { System } from "..";
import Event from "../nostr/Event";
import EventKind from "../nostr/EventKind";
import Tag from "../nostr/Tag";
import { bech32ToHex } from "../Util"
export default function useEventPublisher() {
const pubKey = useSelector(s => s.login.publicKey);
const privKey = useSelector(s => s.login.privateKey);
const follows = useSelector(s => s.login.follows);
const relays = useSelector(s => s.login.relays);
const hasNip07 = 'nostr' in window;
/**
*
* @param {Event} ev
* @param {*} privKey
* @returns
*/
async function signEvent(ev) {
if (hasNip07 && !privKey) {
ev.Id = await ev.CreateId();
let tmpEv = await barierNip07(() => window.nostr.signEvent(ev.ToObject()));
return Event.FromObject(tmpEv);
} else {
await ev.Sign(privKey);
}
return ev;
}
function processMentions(ev, msg) {
const replaceNpub = (match) => {
const npub = match.slice(1);
try {
const hex = bech32ToHex(npub);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["p", hex], idx));
return `#[${idx}]`
} catch (error) {
return match
}
}
let content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub)
ev.Content = content;
}
return {
broadcast: (ev) => {
console.debug("Sending event: ", ev);
System.BroadcastEvent(ev);
},
metadata: async (obj) => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.SetMetadata;
ev.Content = JSON.stringify(obj);
return await signEvent(ev, privKey);
},
note: async (msg) => {
if (typeof msg !== "string") {
throw "Must be text!";
}
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
processMentions(ev, msg);
return await signEvent(ev);
},
/**
* Reply to a note
* @param {Event} replyTo
* @param {String} msg
* @returns
*/
reply: async (replyTo, msg) => {
if (typeof msg !== "string") {
throw "Must be text!";
}
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
let thread = replyTo.Thread;
if (thread) {
if (thread.Root) {
ev.Tags.push(new Tag(["e", thread.Root.Event, "", "root"], ev.Tags.length));
} else {
ev.Tags.push(new Tag(["e", thread.ReplyTo.Event, "", "root"], ev.Tags.length));
}
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length));
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
for (let pk of thread.PubKeys) {
if (pk === pubKey) {
continue; // dont tag self in replies
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
} else {
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0));
ev.Tags.push(new Tag(["p", replyTo.PubKey], 1));
}
processMentions(ev, msg);
return await signEvent(ev);
},
react: async (evRef, content = "+") => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.Reaction;
ev.Content = content;
ev.Tags.push(new Tag(["e", evRef.Id], 0));
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
return await signEvent(ev);
},
saveRelays: async () => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (let pk of follows) {
ev.Tags.push(new Tag(["p", pk]));
}
return await signEvent(ev);
},
addFollow: async (pkAdd) => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
let temp = new Set(follows);
if (Array.isArray(pkAdd)) {
pkAdd.forEach(a => temp.add(a));
} else {
temp.add(pkAdd);
}
for (let pk of temp) {
ev.Tags.push(new Tag(["p", pk]));
}
return await signEvent(ev);
},
removeFollow: async (pkRemove) => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (let pk of follows) {
if (pk === pkRemove) {
continue;
}
ev.Tags.push(new Tag(["p", pk]));
}
return await signEvent(ev);
},
delete: async (id) => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.Deletion;
ev.Content = "";
ev.Tags.push(new Tag(["e", id]));
return await signEvent(ev);
},
/**
* Respot a note
* @param {Event} note
* @returns
*/
repost: async (note) => {
if (typeof note.Id !== "string") {
throw "Must be parsed note in Event class";
}
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.Repost;
ev.Content = JSON.stringify(note.Original);
ev.Tags.push(new Tag(["e", note.Id]));
ev.Tags.push(new Tag(["p", note.PubKey]));
return await signEvent(ev);
},
decryptDm: async (note) => {
if (note.PubKey !== pubKey && !note.Tags.some(a => a.PubKey === pubKey)) {
return "<CANT DECRYPT>";
}
try {
let otherPubKey = note.PubKey === pubKey ? note.Tags.filter(a => a.Key === "p")[0].PubKey : note.PubKey;
if (hasNip07 && !privKey) {
return await barierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.Content));
} else if(privKey) {
await note.DecryptDm(privKey, otherPubKey);
return note.Content;
}
} catch (e) {
console.error("Decyrption failed", e);
return "<DECRYPTION FAILED>";
}
return "test";
},
sendDm: async (content, to) => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.DirectMessage;
ev.Content = content;
ev.Tags.push(new Tag(["p", to]));
try {
if (hasNip07 && !privKey) {
let cx = await barierNip07(() => window.nostr.nip04.encrypt(to, content));
ev.Content = cx;
return await signEvent(ev);
} else if(privKey) {
await ev.EncryptDmForPubkey(to, privKey);
return await signEvent(ev);
}
} catch (e) {
console.error("Encryption failed", e);
}
}
}
}
let isNip07Busy = false;
const delay = (t) => {
return new Promise((resolve, reject) => {
setTimeout(resolve, t);
});
}
const barierNip07 = async (then) => {
while (isNip07Busy) {
await delay(10);
}
isNip07Busy = true;
try {
return await then();
} finally {
isNip07Busy = false;
}
};

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

@ -0,0 +1,261 @@
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 | undefined) => {
if (ev) {
console.debug("Sending event: ", ev);
System.BroadcastEvent(ev);
}
},
metadata: async (obj: UserMetadata) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.SetMetadata;
ev.Content = JSON.stringify(obj);
return await signEvent(ev);
}
},
note: async (msg: string) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
processMentions(ev, msg);
return await signEvent(ev);
}
},
/**
* Reply to a note
*/
reply: async (replyTo: NEvent, msg: string) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
let thread = replyTo.Thread;
if (thread) {
if (thread.Root || thread.ReplyTo) {
ev.Tags.push(new Tag(["e", thread.Root?.Event ?? thread.ReplyTo?.Event!, "", "root"], ev.Tags.length));
}
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length));
// dont tag self in replies
if (replyTo.PubKey !== pubKey) {
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
}
for (let pk of thread.PubKeys) {
if (pk === pubKey) {
continue; // dont tag self in replies
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
} else {
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0));
// dont tag self in replies
if (replyTo.PubKey !== pubKey) {
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
}
}
processMentions(ev, msg);
return await signEvent(ev);
}
},
react: async (evRef: NEvent, content = "+") => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Reaction;
ev.Content = content;
ev.Tags.push(new Tag(["e", evRef.Id], 0));
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
return await signEvent(ev);
}
},
saveRelays: async () => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (let pk of follows) {
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
addFollow: async (pkAdd: HexKey) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
let temp = new Set(follows);
if (Array.isArray(pkAdd)) {
pkAdd.forEach(a => temp.add(a));
} else {
temp.add(pkAdd);
}
for (let pk of temp) {
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
removeFollow: async (pkRemove: HexKey) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (let pk of follows) {
if (pk === pkRemove) {
continue;
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
/**
* Delete an event (NIP-09)
*/
delete: async (id: u256) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Deletion;
ev.Content = "";
ev.Tags.push(new Tag(["e", id], 0));
return await signEvent(ev);
}
},
/**
* Respot a note (NIP-18)
*/
repost: async (note: NEvent) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Repost;
ev.Content = JSON.stringify(note.Original);
ev.Tags.push(new Tag(["e", note.Id], 0));
ev.Tags.push(new Tag(["p", note.PubKey], 1));
return await signEvent(ev);
}
},
decryptDm: async (note: NEvent): Promise<string | undefined> => {
if (pubKey) {
if (note.PubKey !== pubKey && !note.Tags.some(a => a.PubKey === pubKey)) {
return "<CANT DECRYPT>";
}
try {
let otherPubKey = note.PubKey === pubKey ? note.Tags.filter(a => a.Key === "p")[0].PubKey! : note.PubKey;
if (hasNip07 && !privKey) {
return await barierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.Content));
} else if (privKey) {
await note.DecryptDm(privKey, otherPubKey);
return note.Content;
}
} catch (e) {
console.error("Decyrption failed", e);
return "<DECRYPTION FAILED>";
}
}
},
sendDm: async (content: string, to: HexKey) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.DirectMessage;
ev.Content = content;
ev.Tags.push(new Tag(["p", to], 0));
try {
if (hasNip07 && !privKey) {
let cx: string = await barierNip07(() => window.nostr.nip04.encrypt(to, content));
ev.Content = cx;
return await signEvent(ev);
} else if (privKey) {
await ev.EncryptDmForPubkey(to, privKey);
return await signEvent(ev);
}
} catch (e) {
console.error("Encryption failed", e);
}
}
}
}
}
let isNip07Busy = false;
const delay = (t: number) => {
return new Promise((resolve, reject) => {
setTimeout(resolve, t);
});
}
const barierNip07 = async (then: () => Promise<any>) => {
while (isNip07Busy) {
await delay(10);
}
isNip07Busy = true;
try {
return await then();
} finally {
isNip07Busy = false;
}
};

View File

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

View File

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

View File

@ -1,19 +1,20 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { HexKey } from "../nostr";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import { Subscriptions } from "../nostr/Subscriptions"; import { Subscriptions } from "../nostr/Subscriptions";
import { addDirectMessage, addNotifications, setFollows, setRelays } from "../state/Login"; import { addDirectMessage, addNotifications, setFollows, setRelays } from "../state/Login";
import { setUserData } from "../state/Users"; import { RootState } from "../state/Store";
import { db } from "../db"; import { db } from "../db";
import useSubscription from "./Subscription"; import useSubscription from "./Subscription";
import { mapEventToProfile } from "./UsersFeed"; import { mapEventToProfile } from "../db/User";
/** /**
* Managed loading data for the current logged in user * Managed loading data for the current logged in user
*/ */
export default function useLoginFeed() { export default function useLoginFeed() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [pubKey, readNotifications] = useSelector(s => [s.login.publicKey, s.login.readNotifications]); const [pubKey, readNotifications] = useSelector<RootState, [HexKey | undefined, number]>(s => [s.login.publicKey, s.login.readNotifications]);
const sub = useMemo(() => { const sub = useMemo(() => {
if (!pubKey) { if (!pubKey) {
@ -22,29 +23,28 @@ export default function useLoginFeed() {
let sub = new Subscriptions(); let sub = new Subscriptions();
sub.Id = `login:${sub.Id}`; sub.Id = `login:${sub.Id}`;
sub.Authors.add(pubKey); sub.Authors = new Set([pubKey]);
sub.Kinds.add(EventKind.ContactList); sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata, EventKind.DirectMessage]);
sub.Kinds.add(EventKind.SetMetadata);
sub.Kinds.add(EventKind.DirectMessage);
let notifications = new Subscriptions(); let notifications = new Subscriptions();
notifications.Kinds.add(EventKind.TextNote); notifications.Kinds = new Set([EventKind.TextNote, EventKind.DirectMessage]);
notifications.Kinds.add(EventKind.DirectMessage); notifications.PTags = new Set([pubKey]);
notifications.PTags.add(pubKey);
notifications.Limit = 100; notifications.Limit = 100;
sub.AddSubscription(notifications); sub.AddSubscription(notifications);
return sub; return sub;
}, [pubKey]); }, [pubKey]);
const { notes } = useSubscription(sub, { leaveOpen: true }); const main = useSubscription(sub, { leaveOpen: true });
useEffect(() => { useEffect(() => {
let contactList = notes.filter(a => a.kind === EventKind.ContactList); let contactList = main.notes.filter(a => a.kind === EventKind.ContactList);
let notifications = notes.filter(a => a.kind === EventKind.TextNote); let notifications = main.notes.filter(a => a.kind === EventKind.TextNote);
let metadata = notes.filter(a => a.kind === EventKind.SetMetadata) let metadata = main.notes.filter(a => a.kind === EventKind.SetMetadata);
let profiles = metadata.map(a => mapEventToProfile(a)); let profiles = metadata.map(a => mapEventToProfile(a))
let dms = notes.filter(a => a.kind === EventKind.DirectMessage); .filter(a => a !== undefined)
.map(a => a!);
let dms = main.notes.filter(a => a.kind === EventKind.DirectMessage);
for (let cl of contactList) { for (let cl of contactList) {
if (cl.content !== "") { if (cl.content !== "") {
@ -62,11 +62,7 @@ export default function useLoginFeed() {
} }
} }
dispatch(addNotifications(notifications)); dispatch(addNotifications(notifications));
dispatch(setUserData(profiles)); db.users.bulkPut(profiles);
const userMetadata = metadata.map(ev => {
return {...JSON.parse(ev.content), pubkey: ev.pubkey }
})
db.users.bulkPut(metadata);
dispatch(addDirectMessage(dms)); dispatch(addDirectMessage(dms));
}, [notes]); }, [main]);
} }

View File

@ -1,16 +0,0 @@
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { addPubKey } from "../state/Users";
export default function useProfile(pubKey) {
const dispatch = useDispatch();
const user = useSelector(s => s.users.users[pubKey]);
useEffect(() => {
if (pubKey) {
dispatch(addPubKey(pubKey));
}
}, [pubKey]);
return user;
}

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

@ -0,0 +1,27 @@
import { useLiveQuery } from "dexie-react-hooks";
import { useEffect, useMemo } from "react";
import { db } from "../db";
import { HexKey } from "../nostr";
import { System } from "../nostr/System";
export default function useProfile(pubKey: HexKey | Array<HexKey>) {
const user = useLiveQuery(async () => {
if (pubKey) {
if (Array.isArray(pubKey)) {
let ret = await db.users.bulkGet(pubKey);
return ret.filter(a => a !== undefined).map(a => a!);
} else {
return await db.users.get(pubKey);
}
}
}, [pubKey]);
useEffect(() => {
if (pubKey) {
System.TrackMetadata(pubKey);
return () => System.UntrackMetadata(pubKey);
}
}, [pubKey]);
return user;
}

View File

@ -1,9 +0,0 @@
import { useSyncExternalStore } from "react";
import { System } from "..";
const noop = () => {};
export default function useRelayState(addr) {
let c = System.Sockets[addr];
return useSyncExternalStore(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noop);
}

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

@ -0,0 +1,11 @@
import { useSyncExternalStore } from "react";
import { System } from "../nostr/System";
import { CustomHook } from "../nostr/Connection";
const noop = (f: CustomHook) => { return () => { }; };
const noopState = () => { };
export default function useRelayState(addr: string) {
let c = System.Sockets.get(addr);
return useSyncExternalStore(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noopState);
}

View File

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

View File

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

View File

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

View File

@ -1,61 +0,0 @@
import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { ProfileCacheExpire } from "../Const";
import EventKind from "../nostr/EventKind";
import { db } from "../db";
import { Subscriptions } from "../nostr/Subscriptions";
import { 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);
function isUserCached(id) {
let expire = new Date().getTime() - ProfileCacheExpire;
let u = users[id];
return u && u.loaded > expire;
}
const sub = useMemo(() => {
let needProfiles = pKeys.filter(a => !isUserCached(a));
if (needProfiles.length === 0) {
return null;
}
let sub = new Subscriptions();
sub.Id = `profiles:${sub.Id}`;
sub.Authors = new Set(needProfiles.slice(0, 20));
sub.Kinds.add(EventKind.SetMetadata);
return sub;
}, [pKeys]);
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 }
});
db.users.bulkPut(profiles);
}, [results]);
return results;
}
export function mapEventToProfile(ev) {
try {
let data = JSON.parse(ev.content);
return {
pubkey: ev.pubkey,
fromEvent: ev,
loaded: new Date().getTime(),
...data
};
} catch (e) {
console.error("Failed to parse JSON", ev, e);
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,44 @@
/**
* Stats class for tracking metrics per connection
*/
export class ConnectionStats {
/**
* Last n records of how long between REQ->EOSE
*/
Latency: number[];
/**
* Total number of REQ's sent on this connection
*/
Subs: number;
/**
* Count of REQ which took too long and where abandoned
*/
SubsTimeout: number;
/**
* Total number of EVENT messages received
*/
EventsReceived: number;
/**
* Total number of EVENT messages sent
*/
EventsSent: number;
/**
* Total number of times this connection was lost
*/
Disconnects: number;
constructor() {
this.Latency = [];
this.Subs = 0;
this.SubsTimeout = 0;
this.EventsReceived = 0;
this.EventsSent = 0;
this.Disconnects = 0;
}
}

View File

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

View File

@ -1,13 +0,0 @@
const EventKind = {
Unknown: -1,
SetMetadata: 0,
TextNote: 1,
RecommendServer: 2,
ContactList: 3, // NIP-02
DirectMessage: 4, // NIP-04
Deletion: 5, // NIP-09
Repost: 6, // NIP-18
Reaction: 7 // NIP-25
};
export default EventKind;

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

@ -0,0 +1,13 @@
const enum EventKind {
Unknown = -1,
SetMetadata = 0,
TextNote = 1,
RecommendServer = 2,
ContactList = 3, // NIP-02
DirectMessage = 4, // NIP-04
Deletion = 5, // NIP-09
Repost = 6, // NIP-18
Reaction = 7 // NIP-25
};
export default EventKind;

View File

@ -1,143 +0,0 @@
import { v4 as uuid } from "uuid";
import Connection from "./Connection";
export class Subscriptions {
constructor() {
/**
* A unique id for this subscription filter
*/
this.Id = uuid();
/**
* a list of event ids or prefixes
*/
this.Ids = new Set();
/**
* a list of pubkeys or prefixes, the pubkey of an event must be one of these
*/
this.Authors = new Set();
/**
* a list of a kind numbers
*/
this.Kinds = new Set();
/**
* a list of event ids that are referenced in an "e" tag
*/
this.ETags = new Set();
/**
* a list of pubkeys that are referenced in a "p" tag
*/
this.PTags = new Set();
/**
* a timestamp, events must be newer than this to pass
*/
this.Since = NaN;
/**
* a timestamp, events must be older than this to pass
*/
this.Until = NaN;
/**
* maximum number of events to be returned in the initial query
*/
this.Limit = NaN;
/**
* Handler function for this event
*/
this.OnEvent = (e) => { console.warn(`No event handler was set on subscription: ${this.Id}`) };
/**
* End of data event
* @param {Connection} c
*/
this.OnEnd = (c) => {};
/**
* Collection of OR sub scriptions linked to this
*/
this.OrSubs = [];
/**
* Start time for this subscription
*/
this.Started = {};
/**
* End time for this subscription
*/
this.Finished = {};
}
/**
* Adds OR filter subscriptions
* @param {Subscriptions} sub Extra filters
*/
AddSubscription(sub) {
this.OrSubs.push(sub);
}
/**
* If all relays have responded with EOSE
* @returns {boolean}
*/
IsFinished() {
return Object.keys(this.Started).length === Object.keys(this.Finished).length;
}
static FromObject(obj) {
let ret = new Subscriptions();
ret.Ids = new Set(obj.ids);
ret.Authors = new Set(obj.authors);
ret.Kinds = new Set(obj.kinds);
ret.ETags = new Set(obj["#e"]);
ret.PTags = new Set(obj["#p"]);
ret.Since = parseInt(obj.since);
ret.Until = parseInt(obj.until);
ret.Limit = parseInt(obj.limit);
return ret;
}
ToObject() {
let ret = {};
if (this.Ids.size > 0) {
ret.ids = Array.from(this.Ids);
}
if (this.Authors.size > 0) {
ret.authors = Array.from(this.Authors);
}
if (this.Kinds.size > 0) {
ret.kinds = Array.from(this.Kinds);
}
if (this.ETags.size > 0) {
ret["#e"] = Array.from(this.ETags);
}
if (this.PTags.size > 0) {
ret["#p"] = Array.from(this.PTags);
}
if (!isNaN(this.Since)) {
ret.since = this.Since;
}
if (!isNaN(this.Until)) {
ret.until = this.Until;
}
if (!isNaN(this.Limit)) {
ret.limit = this.Limit;
}
return ret;
}
/**
* Split subscription by ids
* @param {number} n How many segments to create
*/
Split(n) {
}
}

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

@ -0,0 +1,139 @@
import { v4 as uuid } from "uuid";
import { TaggedRawEvent, RawReqFilter, u256 } from ".";
import Connection from "./Connection";
import EventKind from "./EventKind";
export type NEventHandler = (e: TaggedRawEvent) => void;
export type OnEndHandler = (c: Connection) => void;
export class Subscriptions {
/**
* A unique id for this subscription filter
*/
Id: u256;
/**
* a list of event ids or prefixes
*/
Ids: Set<u256> | null
/**
* a list of pubkeys or prefixes, the pubkey of an event must be one of these
*/
Authors: Set<u256> | null;
/**
* a list of a kind numbers
*/
Kinds: Set<EventKind> | null;
/**
* a list of event ids that are referenced in an "e" tag
*/
ETags: Set<u256> | null;
/**
* a list of pubkeys that are referenced in a "p" tag
*/
PTags: Set<u256> | null;
/**
* a timestamp, events must be newer than this to pass
*/
Since: number | null;
/**
* a timestamp, events must be older than this to pass
*/
Until: number | null;
/**
* maximum number of events to be returned in the initial query
*/
Limit: number | null;
/**
* Handler function for this event
*/
OnEvent: NEventHandler;
/**
* End of data event
*/
OnEnd: OnEndHandler;
/**
* Collection of OR sub scriptions linked to this
*/
OrSubs: Array<Subscriptions>;
/**
* Start time for this subscription
*/
Started: Map<string, number>;
/**
* End time for this subscription
*/
Finished: Map<string, number>;
constructor(sub?: RawReqFilter) {
this.Id = uuid();
this.Ids = sub?.ids ? new Set(sub.ids) : null;
this.Authors = sub?.authors ? new Set(sub.authors) : null;
this.Kinds = sub?.kinds ? new Set(sub.kinds) : null;
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : null;
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : null;
this.Since = sub?.since ?? null;
this.Until = sub?.until ?? null;
this.Limit = sub?.limit ?? null;
this.OnEvent = (e) => { console.warn(`No event handler was set on subscription: ${this.Id}`) };
this.OnEnd = (c) => { };
this.OrSubs = [];
this.Started = new Map<string, number>();
this.Finished = new Map<string, number>();
}
/**
* Adds OR filter subscriptions
*/
AddSubscription(sub: Subscriptions) {
this.OrSubs.push(sub);
}
/**
* If all relays have responded with EOSE
*/
IsFinished() {
return this.Started.size === this.Finished.size;
}
ToObject(): RawReqFilter {
let ret: RawReqFilter = {};
if (this.Ids) {
ret.ids = Array.from(this.Ids);
}
if (this.Authors) {
ret.authors = Array.from(this.Authors);
}
if (this.Kinds) {
ret.kinds = Array.from(this.Kinds);
}
if (this.ETags) {
ret["#e"] = Array.from(this.ETags);
}
if (this.PTags) {
ret["#p"] = Array.from(this.PTags);
}
if (this.Since !== null) {
ret.since = this.Since;
}
if (this.Until !== null) {
ret.until = this.Until;
}
if (this.Limit !== null) {
ret.limit = this.Limit;
}
return ret;
}
}

View File

@ -1,98 +0,0 @@
import Connection from "./Connection";
/**
* Manages nostr content retrival system
*/
export class NostrSystem {
constructor() {
this.Sockets = {};
this.Subscriptions = {};
this.PendingSubscriptions = [];
}
/**
* Connect to a NOSTR relay if not already connected
* @param {string} address
*/
ConnectToRelay(address, options) {
try {
if (typeof this.Sockets[address] === "undefined") {
let c = new Connection(address, options);
for (let s of Object.values(this.Subscriptions)) {
c.AddSubscription(s);
}
this.Sockets[address] = c;
}
} catch (e) {
console.error(e);
}
}
DisconnectRelay(address) {
let c = this.Sockets[address];
delete this.Sockets[address];
if (c) {
c.Close();
}
}
AddSubscription(sub) {
for (let s of Object.values(this.Sockets)) {
s.AddSubscription(sub);
}
this.Subscriptions[sub.Id] = sub;
}
RemoveSubscription(subId) {
for (let s of Object.values(this.Sockets)) {
s.RemoveSubscription(subId);
}
delete this.Subscriptions[subId];
}
/**
* Send events to writable relays
* @param {Event} ev
*/
BroadcastEvent(ev) {
for (let s of Object.values(this.Sockets)) {
s.SendEvent(ev);
}
}
/**
* Request/Response pattern
* @param {Subscriptions} sub
* @returns {Array<any>}
*/
RequestSubscription(sub) {
return new Promise((resolve, reject) => {
let events = [];
// force timeout returning current results
let timeout = setTimeout(() => {
this.RemoveSubscription(sub.Id);
resolve(events);
}, 10_000);
let onEventPassthrough = sub.OnEvent;
sub.OnEvent = (ev) => {
if (typeof onEventPassthrough === "function") {
onEventPassthrough(ev);
}
if (!events.some(a => a.id === ev.id)) {
events.push(ev);
}
};
sub.OnEnd = (c) => {
c.RemoveSubscription(sub.Id);
if (sub.IsFinished()) {
clearInterval(timeout);
console.debug(`[${sub.Id}] Finished`);
resolve(events);
}
};
this.AddSubscription(sub);
});
}
}

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

@ -0,0 +1,199 @@
import { HexKey, TaggedRawEvent } from ".";
import { ProfileCacheExpire } from "../Const";
import { db } from "../db";
import { mapEventToProfile, MetadataCache } from "../db/User";
import Connection, { RelaySettings } from "./Connection";
import Event from "./Event";
import EventKind from "./EventKind";
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[];
/**
* List of pubkeys to fetch metadata for
*/
WantsMetadata: Set<HexKey>;
constructor() {
this.Sockets = new Map();
this.Subscriptions = new Map();
this.PendingSubscriptions = [];
this.WantsMetadata = new Set();
this._FetchMetadata()
}
/**
* 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 profile metadata for a set of pubkeys
*/
TrackMetadata(pk: HexKey | Array<HexKey>) {
for (let p of Array.isArray(pk) ? pk : [pk]) {
if (p.length > 0) {
this.WantsMetadata.add(p);
}
}
}
/**
* Stop tracking metadata for a set of pubkeys
*/
UntrackMetadata(pk: HexKey | Array<HexKey>) {
for (let p of Array.isArray(pk) ? pk : [pk]) {
if (p.length > 0) {
this.WantsMetadata.delete(p);
}
}
}
/**
* Request/Response pattern
*/
RequestSubscription(sub: Subscriptions) {
return new Promise<TaggedRawEvent[]>((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);
});
}
async _FetchMetadata() {
let missing = new Set<HexKey>();
let meta = await db.users.bulkGet(Array.from(this.WantsMetadata));
let now = new Date().getTime();
for (let pk of this.WantsMetadata) {
let m = meta.find(a => a?.pubkey === pk);
if (!m || m.loaded < (now - ProfileCacheExpire)) {
missing.add(pk);
// cap 100 missing profiles
if (missing.size >= 100) {
break;
}
}
}
if (missing.size > 0) {
console.debug("Wants profiles: ", missing);
let sub = new Subscriptions();
sub.Id = `profiles:${sub.Id}`;
sub.Kinds = new Set([EventKind.SetMetadata]);
sub.Authors = missing;
sub.OnEvent = async (e) => {
let profile = mapEventToProfile(e);
if (profile) {
await db.users.put(profile);
}
}
let results = await this.RequestSubscription(sub);
let couldNotFetch = Array.from(missing).filter(a => !results.some(b => b.pubkey === a));
console.debug("No profiles: ", couldNotFetch);
await db.users.bulkPut(couldNotFetch.map(a => {
return {
pubkey: a,
loaded: new Date().getTime()
} as MetadataCache;
}));
}
setTimeout(() => this._FetchMetadata(), 500);
}
}
export const System = new NostrSystem();

View File

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

View File

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

View File

@ -1,9 +1,55 @@
export interface RawEvent { export type RawEvent = {
id: string, id: u256,
pubkey: string, pubkey: HexKey,
created_at: number, created_at: number,
kind: number, kind: number,
tags: string[][], tags: string[][],
content: string, content: string,
sig: string sig: string
} }
export interface TaggedRawEvent extends RawEvent {
/**
* A list of relays this event was seen on
*/
relays: string[]
}
/**
* Basic raw key as hex
*/
export type HexKey = string;
/**
* A 256bit hex id
*/
export type u256 = string;
/**
* Raw REQ filter object
*/
export type RawReqFilter = {
ids?: u256[],
authors?: u256[],
kinds?: number[],
"#e"?: u256[],
"#p"?: u256[],
since?: number,
until?: number,
limit?: number
}
/**
* Medatadata event content
*/
export type UserMetadata = {
name?: string,
display_name?: string,
about?: string,
picture?: string,
website?: string,
banner?: string,
nip05?: string,
lud06?: string,
lud16?: string
}

View File

@ -1,9 +0,0 @@
export interface User {
name?: string
about?: string
display_name?: string
nip05?: string
pubkey: string
picture?: string
}

View File

@ -21,7 +21,7 @@ type RouterParams = {
export default function ChatPage() { export default function ChatPage() {
const params = useParams<RouterParams>(); const params = useParams<RouterParams>();
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const id = bech32ToHex(params.id); const id = bech32ToHex(params.id ?? "");
const dms = useSelector<any, RawEvent[]>(s => filterDms(s.login.dms, s.login.publicKey)); const dms = useSelector<any, RawEvent[]>(s => filterDms(s.login.dms, s.login.publicKey));
const [content, setContent] = useState<string>(); const [content, setContent] = useState<string>();
const { ref, inView, entry } = useInView(); const { ref, inView, entry } = useInView();
@ -49,15 +49,17 @@ export default function ChatPage() {
}, [inView, dmListRef, sortedDms]); }, [inView, dmListRef, sortedDms]);
async function sendDm() { async function sendDm() {
if (content) {
let ev = await publisher.sendDm(content, id); let ev = await publisher.sendDm(content, id);
console.debug(ev); console.debug(ev);
publisher.broadcast(ev); publisher.broadcast(ev);
setContent(""); setContent(undefined);
}
} }
async function onEnter(e: KeyboardEvent) { async function onEnter(e: KeyboardEvent) {
let isEnter = e.code === "Enter"; let isEnter = e.code === "Enter";
if(isEnter && !e.shiftKey) { if (isEnter && !e.shiftKey) {
await sendDm(); await sendDm();
} }
} }

View File

@ -5,11 +5,10 @@ import { Outlet, useNavigate } from "react-router-dom";
import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons"; import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { System } from ".." import { System } from "../nostr/System"
import ProfileImage from "../element/ProfileImage"; import ProfileImage from "../element/ProfileImage";
import { init } from "../state/Login"; import { init } from "../state/Login";
import useLoginFeed from "../feed/LoginFeed"; import useLoginFeed from "../feed/LoginFeed";
import useUsersCache from "../feed/UsersFeed";
export default function Layout(props) { export default function Layout(props) {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -19,7 +18,6 @@ export default function Layout(props) {
const relays = useSelector(s => s.login.relays); const relays = useSelector(s => s.login.relays);
const notifications = useSelector(s => s.login.notifications); const notifications = useSelector(s => s.login.notifications);
const readNotifications = useSelector(s => s.login.readNotifications); const readNotifications = useSelector(s => s.login.readNotifications);
useUsersCache();
useLoginFeed(); useLoginFeed();
useEffect(() => { useEffect(() => {
@ -27,7 +25,7 @@ export default function Layout(props) {
for (let [k, v] of Object.entries(relays)) { for (let [k, v] of Object.entries(relays)) {
System.ConnectToRelay(k, v); System.ConnectToRelay(k, v);
} }
for (let [k, v] of Object.entries(System.Sockets)) { for (let [k, v] of System.Sockets) {
if (!relays[k]) { if (!relays[k]) {
System.DisconnectRelay(k); System.DisconnectRelay(k);
} }

View File

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

View File

@ -136,7 +136,7 @@ export default function ProfilePage() {
return ( return (
<> <>
<div className="profile flex"> <div className="profile flex">
{user?.banner && <img alt="banner" className="banner" src={user.banner} /> } {user?.banner && <img alt="banner" className="banner" src={user.banner} />}
{user?.banner ? ( {user?.banner ? (
<> <>
{avatar()} {avatar()}
@ -150,12 +150,9 @@ export default function ProfilePage() {
)} )}
</div> </div>
<div className="tabs"> <div className="tabs">
{ {Object.entries(ProfileTab).map(([k, v]) => {
Object.entries(ProfileTab).map(([k, v]) => {
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={k} onClick={() => setTab(v)}>{k}</div> return <div className={`tab f-1${tab === v ? " active" : ""}`} key={k} onClick={() => setTab(v)}>{k}</div>
} })}
)
}
</div> </div>
{tabContent()} {tabContent()}
</> </>

View File

@ -11,7 +11,6 @@ import useEventPublisher from "../feed/EventPublisher";
import useProfile from "../feed/ProfileFeed"; import useProfile from "../feed/ProfileFeed";
import VoidUpload from "../feed/VoidUpload"; import VoidUpload from "../feed/VoidUpload";
import { logout, setRelays } from "../state/Login"; import { logout, setRelays } from "../state/Login";
import { resetProfile } from "../state/Users";
import { hexToBech32, openFile } from "../Util"; import { hexToBech32, openFile } from "../Util";
import Relay from "../element/Relay"; import Relay from "../element/Relay";
import Copy from "../element/Copy"; import Copy from "../element/Copy";
@ -87,7 +86,6 @@ export default function SettingsPage(props) {
let ev = await publisher.metadata(userCopy); let ev = await publisher.metadata(userCopy);
console.debug(ev); console.debug(ev);
dispatch(resetProfile(id));
publisher.broadcast(ev); publisher.broadcast(ev);
} }

View File

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

View File

@ -1,12 +0,0 @@
import { configureStore } from "@reduxjs/toolkit";
import { reducer as UsersReducer } from "./Users";
import { reducer as LoginReducer } from "./Login";
const Store = configureStore({
reducer: {
users: UsersReducer,
login: LoginReducer
}
});
export default Store;

13
src/state/Store.ts Normal file
View File

@ -0,0 +1,13 @@
import { configureStore } from "@reduxjs/toolkit";
import { reducer as LoginReducer } from "./Login";
const store = configureStore({
reducer: {
login: LoginReducer
}
});
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export default store;

View File

@ -1,97 +0,0 @@
import { createSlice } from '@reduxjs/toolkit'
import { ProfileCacheExpire } from '../Const';
import { db } from '../db';
const UsersSlice = createSlice({
name: "Users",
initialState: {
/**
* Set of known pubkeys
*/
pubKeys: [],
/**
* User objects for known pubKeys, populated async
*/
users: {},
},
reducers: {
addPubKey: (state, action) => {
let keys = action.payload;
if (!Array.isArray(keys)) {
keys = [keys];
}
let changes = false;
let fromCache = false;
let temp = new Set(state.pubKeys);
for (let k of keys) {
if (!temp.has(k)) {
changes = true;
temp.add(k);
// load from cache
let cache = window.localStorage.getItem(`user:${k}`);
if (cache) {
let ud = JSON.parse(cache);
if (ud.loaded > new Date().getTime() - ProfileCacheExpire) {
state.users[ud.pubkey] = ud;
fromCache = true;
}
}
}
}
if (changes) {
state.pubKeys = Array.from(temp);
if (fromCache) {
state.users = {
...state.users
};
}
}
},
setUserData: (state, action) => {
let ud = action.payload;
if (!Array.isArray(ud)) {
ud = [ud];
}
for (let x of ud) {
let existing = state.users[x.pubkey];
if (existing) {
if (existing.fromEvent.created_at > x.fromEvent.created_at) {
// prevent patching with older metadata
continue;
}
x = {
...existing,
...x
};
}
state.users[x.pubkey] = x;
db.users.put({
pubkey: x.pubkey,
name: x.name,
display_name: x.display_name,
nip05: x.nip05,
picture: x.picture,
})
window.localStorage.setItem(`user:${x.pubkey}`, JSON.stringify(x));
state.users = {
...state.users
};
}
},
resetProfile: (state, action) => {
if (state.users[action.payload]) {
delete state.users[action.payload];
state.users = {
...state.users
};
}
}
}
});
export const { addPubKey, setUserData, resetProfile } = UsersSlice.actions;
export const reducer = UsersSlice.reducer;

View File

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

View File

@ -2127,6 +2127,10 @@
integrity sha512-e1DZGD+eH19BnllTWCGXAdrMa2kI53wEMuhn/d+wUmnu8//ZI6BiuK/EPdw07fI4+tlyo5qdPZdXdpkoXHJVOw== integrity sha512-e1DZGD+eH19BnllTWCGXAdrMa2kI53wEMuhn/d+wUmnu8//ZI6BiuK/EPdw07fI4+tlyo5qdPZdXdpkoXHJVOw==
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/uuid@^9.0.0":
version "9.0.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.0.tgz#53ef263e5239728b56096b0a869595135b7952d2"
integrity sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==
"@types/ws@^8.5.1": "@types/ws@^8.5.1":
version "8.5.4" version "8.5.4"