diff --git a/package.json b/package.json index 6abd624f..b2f96b44 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@types/react-dom": "^18.0.10", "@types/webscopeio__react-textarea-autocomplete": "^4.7.2", "@webscopeio/react-textarea-autocomplete": "^4.9.2", + "@types/uuid": "^9.0.0", "bech32": "^2.0.0", "dexie": "^3.2.2", "dexie-react-hooks": "^1.1.1", diff --git a/src/Const.js b/src/Const.ts similarity index 90% rename from src/Const.js rename to src/Const.ts index 8139883a..9993be47 100644 --- a/src/Const.js +++ b/src/Const.ts @@ -1,3 +1,4 @@ +import { RelaySettings } from "./nostr/Connection"; /** * Websocket re-connect timeout @@ -12,11 +13,11 @@ export const ProfileCacheExpire = (1_000 * 60 * 5); /** * Default bootstrap relays */ -export const DefaultRelays = { - "wss://relay.snort.social": { read: true, write: true }, - "wss://relay.damus.io": { read: true, write: true }, - "wss://nostr-pub.wellorder.net": { read: true, write: true } -}; +export const DefaultRelays = new Map([ + ["wss://relay.snort.social", { read: true, write: true }], + ["wss://relay.damus.io", { read: true, write: true }], + ["wss://nostr-pub.wellorder.net", { read: true, write: true }], +]); /** * List of recommended follows for new users diff --git a/src/Util.js b/src/Util.ts similarity index 80% rename from src/Util.js rename to src/Util.ts index 471d6713..30441a87 100644 --- a/src/Util.js +++ b/src/Util.ts @@ -1,12 +1,16 @@ import * as secp from "@noble/secp256k1"; import { bech32 } from "bech32"; +import { HexKey, u256 } from "./nostr"; export async function openFile() { return new Promise((resolve, reject) => { let elm = document.createElement("input"); elm.type = "file"; - elm.onchange = (e) => { - resolve(e.target.files[0]); + elm.onchange = (e: Event) => { + let elm = e.target as HTMLInputElement; + if (elm.files) { + resolve(elm.files[0]); + } }; elm.click(); }); @@ -15,9 +19,9 @@ export async function openFile() { /** * Parse bech32 ids * https://github.com/nostr-protocol/nips/blob/master/19.md - * @param {string} id bech32 id + * @param id bech32 id */ -export function parseId(id) { +export function parseId(id: string) { const hrp = ["note", "npub", "nsec"]; try { if (hrp.some(a => id.startsWith(a))) { @@ -27,7 +31,7 @@ export function parseId(id) { return id; } -export function bech32ToHex(str) { +export function bech32ToHex(str: string) { let nKey = bech32.decode(str); let buff = bech32.fromWords(nKey.words); return secp.utils.bytesToHex(Uint8Array.from(buff)); @@ -35,10 +39,10 @@ export function bech32ToHex(str) { /** * Decode bech32 to string UTF-8 - * @param {string} str bech32 encoded string + * @param str bech32 encoded string * @returns */ -export function bech32ToText(str) { +export function bech32ToText(str: string) { let decoded = bech32.decode(str, 1000); let buf = bech32.fromWords(decoded.words); return new TextDecoder().decode(Uint8Array.from(buf)); @@ -46,10 +50,10 @@ export function bech32ToText(str) { /** * Convert hex note id to bech32 link url - * @param {string} hex + * @param hex * @returns */ -export function eventLink(hex) { +export function eventLink(hex: u256) { return `/e/${hexToBech32("note", hex)}`; } @@ -57,11 +61,11 @@ export function eventLink(hex) { * Convert hex to bech32 * @param {string} hex */ -export function hexToBech32(hrp, hex) { +export function hexToBech32(hrp: string, hex: string) { if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 != 0) { return ""; } - + try { let buf = secp.utils.hexToBytes(hex); return bech32.encode(hrp, bech32.toWords(buf)); @@ -76,7 +80,7 @@ export function hexToBech32(hrp, hex) { * @param {string} hex * @returns */ -export function profileLink(hex) { +export function profileLink(hex: HexKey) { return `/p/${hexToBech32("npub", hex)}`; } @@ -93,7 +97,7 @@ export const Reaction = { * @param {string} content * @returns */ -export function normalizeReaction(content) { +export function normalizeReaction(content: string) { switch (content) { case "": return Reaction.Positive; case "🤙": return Reaction.Positive; @@ -109,10 +113,10 @@ export function normalizeReaction(content) { /** * Converts LNURL service to LN Address - * @param {string} lnurl + * @param lnurl * @returns */ -export function extractLnAddress(lnurl) { +export function extractLnAddress(lnurl: string) { // some clients incorrectly set this to LNURL service, patch this if (lnurl.toLowerCase().startsWith("lnurl")) { let url = bech32ToText(lnurl); diff --git a/src/db/User.ts b/src/db/User.ts new file mode 100644 index 00000000..a8fc7de4 --- /dev/null +++ b/src/db/User.ts @@ -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); + } +} \ No newline at end of file diff --git a/src/db.ts b/src/db/index.ts similarity index 80% rename from src/db.ts rename to src/db/index.ts index 203982d2..d5734ccb 100644 --- a/src/db.ts +++ b/src/db/index.ts @@ -1,9 +1,9 @@ import Dexie, { Table } from 'dexie'; +import { MetadataCache } from './User'; -import type { User } from './nostr/types'; export class SnortDB extends Dexie { - users!: Table; + users!: Table; constructor() { super('snortDB'); diff --git a/src/element/DM.tsx b/src/element/DM.tsx index c38e8266..0bea6f95 100644 --- a/src/element/DM.tsx +++ b/src/element/DM.tsx @@ -24,9 +24,9 @@ export default function DM(props: DMProps) { const { ref, inView, entry } = useInView(); async function decrypt() { - let e = Event.FromObject(props.data); + let e = new Event(props.data); let decrypted = await publisher.decryptDm(e); - setContent(decrypted); + setContent(decrypted || ""); } useEffect(() => { diff --git a/src/element/Nip5Service.tsx b/src/element/Nip5Service.tsx index 1612346b..9a64eef3 100644 --- a/src/element/Nip5Service.tsx +++ b/src/element/Nip5Service.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import { useSelector } from "react-redux"; import { useNavigate } from "react-router-dom"; import { ServiceProvider, @@ -15,14 +15,10 @@ import AsyncButton from "./AsyncButton"; import LNURLTip from "./LNURLTip"; // @ts-ignore import Copy from "./Copy"; -// @ts-ignore import useProfile from "../feed/ProfileFeed"; -// @ts-ignore import useEventPublisher from "../feed/EventPublisher"; -// @ts-ignore -import { resetProfile } from "../state/Users"; -// @ts-ignore import { hexToBech32 } from "../Util"; +import { UserMetadata } from "../nostr"; type Nip05ServiceProps = { name: string, @@ -35,10 +31,9 @@ type Nip05ServiceProps = { type ReduxStore = any; export default function Nip5Service(props: Nip05ServiceProps) { - const dispatch = useDispatch(); const navigate = useNavigate(); const pubkey = useSelector(s => s.login.publicKey); - const user: any = useProfile(pubkey); + const user = useProfile(pubkey); const publisher = useEventPublisher(); const svc = new ServiceProvider(props.service); const [serviceConfig, setServiceConfig] = useState(); @@ -71,11 +66,11 @@ export default function Nip5Service(props: Nip05ServiceProps) { setError(undefined); setAvailabilityResponse(undefined); if (handle && domain) { - if(handle.length < (domainConfig?.length[0] ?? 2)) { + if (handle.length < (domainConfig?.length[0] ?? 2)) { setAvailabilityResponse({ available: false, why: "TOO_SHORT" }); return; } - if(handle.length > (domainConfig?.length[1] ?? 20)) { + if (handle.length > (domainConfig?.length[1] ?? 20)) { setAvailabilityResponse({ available: false, why: "TOO_LONG" }); return; } @@ -149,17 +144,15 @@ export default function Nip5Service(props: Nip05ServiceProps) { } async function updateProfile(handle: string, domain: string) { - let newProfile = { - ...user, - nip05: `${handle}@${domain}` - }; - delete newProfile["loaded"]; - delete newProfile["fromEvent"]; - delete newProfile["pubkey"]; - let ev = await publisher.metadata(newProfile); - dispatch(resetProfile(pubkey)); - publisher.broadcast(ev); - navigate("/settings"); + if (user) { + let newProfile = { + ...user, + nip05: `${handle}@${domain}` + } as UserMetadata; + let ev = await publisher.metadata(newProfile); + publisher.broadcast(ev); + navigate("/settings"); + } } return ( diff --git a/src/element/Note.js b/src/element/Note.js index fc4a5af6..c9f5425c 100644 --- a/src/element/Note.js +++ b/src/element/Note.js @@ -1,6 +1,5 @@ import "./Note.css"; -import { useCallback } from "react"; -import { useSelector } from "react-redux"; +import { useCallback, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import Event from "../nostr/Event"; @@ -9,15 +8,16 @@ import Text from "./Text"; import { eventLink, hexToBech32 } from "../Util"; import NoteFooter from "./NoteFooter"; import NoteTime from "./NoteTime"; +import EventKind from "../nostr/EventKind"; +import useProfile from "../feed/ProfileFeed"; export default function Note(props) { const navigate = useNavigate(); - const opt = props.options; - const dataEvent = props["data-ev"]; - const { data, isThread, reactions, deletion, hightlight } = props + const { data, isThread, reactions, deletion, hightlight, options: opt, ["data-ev"]: parsedEvent } = props + const ev = useMemo(() => parsedEvent ?? new Event(data), [data]); + const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]); - const users = useSelector(s => s.users?.users); - const ev = dataEvent ?? Event.FromObject(data); + const users = useProfile(pubKeys); const options = { showHeader: true, @@ -31,8 +31,8 @@ export default function Note(props) { if (deletion?.length > 0) { return (Deleted); } - return ; - }, [data, dataEvent, reactions, deletion]); + return ; + }, [props]); function goToEvent(e, id) { if (!window.location.pathname.startsWith("/e/")) { @@ -48,7 +48,7 @@ export default function Note(props) { const maxMentions = 2; 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); let othersLength = mentions.length - maxMentions 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 ( <>

Unknown event kind: {ev.Kind}

diff --git a/src/element/NoteCreator.js b/src/element/NoteCreator.js index 9569b881..01a6d60b 100644 --- a/src/element/NoteCreator.js +++ b/src/element/NoteCreator.js @@ -1,5 +1,4 @@ -import { useState, Component } from "react"; -import { useSelector } from "react-redux"; +import { useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faPaperclip } from "@fortawesome/free-solid-svg-icons"; @@ -20,7 +19,6 @@ export function NoteCreator(props) { const [note, setNote] = useState(""); const [error, setError] = useState(""); const [active, setActive] = useState(false); - const users = useSelector((state) => state.users.users) async function sendNote() { let ev = replyTo ? await publisher.reply(replyTo, note) : await publisher.note(note); @@ -71,7 +69,6 @@ export function NoteCreator(props) {