diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..a4d90eb --- /dev/null +++ b/src/events.ts @@ -0,0 +1,61 @@ +import {Event} from 'nostr-tools'; +import {zeroLeadingBitsCount} from './utils'; + +export const isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention'; +export const hasEventTag = (tag: string[]) => tag[0] === 'e'; + +/** + * validate proof-of-work of a nostr event per nip-13. + * the validation always requires difficulty commitment in the nonce tag. + * + * @param {EventObj} evt event to validate + * TODO: @param {number} targetDifficulty target proof-of-work difficulty + */ +export const validatePow = (evt: Event) => { + const tag = evt.tags.find(tag => tag[0] === 'nonce'); + if (!tag) { + return false; + } + const difficultyCommitment = Number(tag[2]); + if (!difficultyCommitment || Number.isNaN(difficultyCommitment)) { + return false; + } + return zeroLeadingBitsCount(evt.id) >= difficultyCommitment; +} + +export const sortByCreatedAt = (evt1: Event, evt2: Event) => { + if (evt1.created_at === evt2.created_at) { + // console.log('TODO: OMG exactly at the same time, figure out how to sort then', evt1, evt2); + } + return evt1.created_at > evt2.created_at ? -1 : 1; +}; + +export const sortEventCreatedAt = (created_at: number) => ( + {created_at: a}: Event, + {created_at: b}: Event, +) => ( + Math.abs(a - created_at) < Math.abs(b - created_at) ? -1 : 1 +); + +const isReply = ([tag, , , marker]: string[]) => tag === 'e' && marker !== 'mention'; + +/** + * find reply-to ID according to nip-10, find marked reply or root tag or + * fallback to positional (last) e tag or return null + * @param {event} evt + * @returns replyToID | null + */ +export const getReplyTo = (evt: Event): string | null => { + const eventTags = evt.tags.filter(isReply); + const withReplyMarker = eventTags.filter(([, , , marker]) => marker === 'reply'); + if (withReplyMarker.length === 1) { + return withReplyMarker[0][1]; + } + const withRootMarker = eventTags.filter(([, , , marker]) => marker === 'root'); + if (withReplyMarker.length === 0 && withRootMarker.length === 1) { + return withRootMarker[0][1]; + } + // fallback to deprecated positional 'e' tags (nip-10) + const lastTag = eventTags.at(-1); + return lastTag ? lastTag[1] : null; +}; diff --git a/src/main.js b/src/main.js index 31e9a3c..edc1dc7 100644 --- a/src/main.js +++ b/src/main.js @@ -1,9 +1,9 @@ import {generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent} from 'nostr-tools'; import {sub24hFeed, subNote, subProfile} from './subscriptions' import {publish} from './relays'; +import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; -import {zeroLeadingBitsCount} from './cryptoutils.js'; -import {bounce, dateTime, elem, formatTime, parseTextContent} from './utils'; +import {bounce, dateTime, elem, formatTime, getHost, getNoxyUrl, isWssUrl, parseTextContent, zeroLeadingBitsCount} from './utils'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ function onEvent(evt, relay) { @@ -39,12 +39,6 @@ let pubkey = localStorage.getItem('pub_key') || (() => { const textNoteList = []; // could use indexDB const eventRelayMap = {}; // eventId: [relay1, relay2] -const hasEventTag = tag => tag[0] === 'e'; -const isReply = ([tag, , , marker]) => tag === 'e' && marker !== 'mention'; -const isMention = ([tag, , , marker]) => tag === 'e' && marker === 'mention'; -const hasEnoughPOW = ([tag, , commitment]) => { - return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(note.id) >= fitlerDifficulty; -}; const renderNote = (evt, i, sortedFeeds) => { if (getViewElem(evt.id)) { // note already in view return; @@ -58,13 +52,17 @@ const renderNote = (evt, i, sortedFeeds) => { setViewElem(evt.id, article); }; +const hasEnoughPOW = ([tag, , commitment], eventId) => { + return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(eventId) >= fitlerDifficulty; +}; + const renderFeed = bounce(() => { const now = Math.floor(Date.now() * 0.001); textNoteList // dont render notes from the future .filter(note => note.created_at <= now) // if difficulty filter is configured dont render notes with too little pow - .filter(note => !fitlerDifficulty || note.tags.some(hasEnoughPOW)) + .filter(note => !fitlerDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id))) .sort(sortByCreatedAt) .reverse() .forEach(renderNote); @@ -161,13 +159,6 @@ function handleReaction(evt, relay) { const restoredReplyTo = localStorage.getItem('reply_to'); -const sortByCreatedAt = (evt1, evt2) => { - if (evt1.created_at === evt2.created_at) { - // console.log('TODO: OMG exactly at the same time, figure out how to sort then', evt1, evt2); - } - return evt1.created_at > evt2.created_at ? -1 : 1; -}; - function rerenderFeed() { clearView(); renderFeed(); @@ -179,17 +170,6 @@ setInterval(() => { }); }, 10000); -const getNoxyUrl = (type, url, id, relay) => { - if (!isHttpUrl(url)) { - return false; - } - const link = new URL(`https://noxy.nostr.ch/${type}`); - link.searchParams.set('id', id); - link.searchParams.set('relay', relay); - link.searchParams.set('url', url); - return link; -} - const fetchQue = []; let fetchPending; const fetchNext = (href, id, relay) => { @@ -300,21 +280,6 @@ function createTextNote(evt, relay) { ], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); } -const sortEventCreatedAt = (created_at) => ( - {created_at: a}, - {created_at: b}, -) => ( - Math.abs(a - created_at) < Math.abs(b - created_at) ? -1 : 1 -); - -function isWssUrl(string) { - try { - return 'wss:' === new URL(string).protocol; - } catch (err) { - return false; - } -} - function handleRecommendServer(evt, relay) { if (getViewElem(evt.id) || !isWssUrl(evt.content)) { return; @@ -326,7 +291,7 @@ function handleRecommendServer(evt, relay) { const closestTextNotes = textNoteList .filter(note => !fitlerDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && commitment >= fitlerDifficulty)) .sort(sortEventCreatedAt(evt.created_at)); - getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed + getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed } setViewElem(evt.id, art); } @@ -450,22 +415,6 @@ function setMetadata(evt, relay, content) { // } } -function isHttpUrl(string) { - try { - return ['http:', 'https:'].includes(new URL(string).protocol); - } catch (err) { - return false; - } -} - -const getHost = (url) => { - try { - return new URL(url).host; - } catch(err) { - return err; - } -} - const elemCanvas = (text) => { const canvas = elem('canvas', {height: 80, width: 80, data: {pubkey: text}}); const context = canvas.getContext('2d'); @@ -499,26 +448,6 @@ function getMetadata(evt, relay) { return {host, img, name, time, userName}; } -/** - * find reply-to ID according to nip-10, find marked reply or root tag or - * fallback to positional (last) e tag or return null - * @param {event} evt - * @returns replyToID | null - */ -function getReplyTo(evt) { - const eventTags = evt.tags.filter(isReply); - const withReplyMarker = eventTags.filter(([, , , marker]) => marker === 'reply'); - if (withReplyMarker.length === 1) { - return withReplyMarker[0][1]; - } - const withRootMarker = eventTags.filter(([, , , marker]) => marker === 'root'); - if (withReplyMarker.length === 0 && withRootMarker.length === 1) { - return withRootMarker[0][1]; - } - // fallback to deprecated positional 'e' tags (nip-10) - return eventTags.length ? eventTags.at(-1)[1] : null; -} - const writeForm = document.querySelector('#writeForm'); const elemShrink = () => { @@ -973,25 +902,6 @@ function promptError(error, options = {}) { errorOverlay.hidden = false; } -/** - * validate proof-of-work of a nostr event per nip-13. - * the validation always requires difficulty commitment in the nonce tag. - * - * @param {EventObj} evt event to validate - * TODO: @param {number} targetDifficulty target proof-of-work difficulty - */ -function validatePow(evt) { - const tag = evt.tags.find(tag => tag[0] === 'nonce'); - if (!tag) { - return false; - } - const difficultyCommitment = Number(tag[2]); - if (!difficultyCommitment || Number.isNaN(difficultyCommitment)) { - return false; - } - return zeroLeadingBitsCount(evt.id) >= difficultyCommitment; -} - /** * run proof of work in a worker until at least the specified difficulty. * if succcessful, the returned event contains the 'nonce' tag diff --git a/src/cryptoutils.js b/src/utils/crypto.ts similarity index 63% rename from src/cryptoutils.js rename to src/utils/crypto.ts index 2c2b265..9cc1d5a 100644 --- a/src/cryptoutils.js +++ b/src/utils/crypto.ts @@ -1,19 +1,19 @@ /** - * evaluate the difficulty of hex32 according to nip-13. - * @param hex32 a string of 64 chars - 32 bytes in hex representation - */ -export const zeroLeadingBitsCount = (hex32) => { + * evaluate the difficulty of hex32 according to nip-13. + * @param hex32 a string of 64 chars - 32 bytes in hex representation + */ +export const zeroLeadingBitsCount = (hex32: string) => { let count = 0; for (let i = 0; i < 64; i += 2) { const hexbyte = hex32.slice(i, i + 2); // grab next byte - if (hexbyte == '00') { + if (hexbyte === '00') { count += 8; continue; } // reached non-zero byte; count number of 0 bits in hexbyte const bits = parseInt(hexbyte, 16).toString(2).padStart(8, '0'); for (let b = 0; b < 8; b++) { - if (bits[b] == '1' ) { + if (bits[b] === '1' ) { break; // reached non-zero bit; stop } count += 1; diff --git a/src/utils/index.ts b/src/utils/index.ts index 5ca6ee9..80973cc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,4 @@ +export {zeroLeadingBitsCount} from './crypto'; export {elem, parseTextContent} from './dom'; export {bounce, dateTime, formatTime} from './time'; +export {getHost, getNoxyUrl, isHttpUrl, isWssUrl} from './url'; diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000..0309cd9 --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,39 @@ +export const getHost = (url: string) => { + try { + return new URL(url).host; + } catch(err) { + return err; + } +}; + +export const isHttpUrl = (url: string) => { + try { + return ['http:', 'https:'].includes(new URL(url).protocol); + } catch (err) { + return false; + } +}; + +export const isWssUrl = (url: string) => { + try { + return 'wss:' === new URL(url).protocol; + } catch (err) { + return false; + } +}; + +export const getNoxyUrl = ( + type: 'data' | 'meta', + url: string, + id: string, + relay: string, +) => { + if (!isHttpUrl(url)) { + return false; + } + const link = new URL(`https://noxy.nostr.ch/${type}`); + link.searchParams.set('id', id); + link.searchParams.set('relay', relay); + link.searchParams.set('url', url); + return link; +}; diff --git a/src/worker.js b/src/worker.js index 2272cd1..97c14aa 100644 --- a/src/worker.js +++ b/src/worker.js @@ -1,5 +1,5 @@ import {getEventHash} from 'nostr-tools'; -import {zeroLeadingBitsCount} from './cryptoutils.js'; +import {zeroLeadingBitsCount} from './utils/crypto'; function mine(event, difficulty, timeout = 5) { const max = 256; // arbitrary diff --git a/tsconfig.json b/tsconfig.json index 2ebb6d9..6e20659 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "target": "es2021" + "moduleResolution": "node", + "target": "es2022" }, "exclude": ["node_modules", "dist", "**/*.test.ts"] -} \ No newline at end of file +}