From a1b1f3baee124f12aca5c5099dbd462cd218eb4c Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 24 Dec 2022 00:02:57 +0100 Subject: [PATCH 1/5] nip-13: mine pow async in worker any only invoke noxy with pow added pow to text notes, reactions and metadata events. pow is mined async in a worker so that the main process does not freeze. noxy profile images, link and image previews are now now only invoked if an event has some valid work proof. noxy can decide if there is enough work and whether or not to serve data for a certain event. target difficulty can be implemented in a later step, this change only check if there is any valid nonce tag with commitment target greater than 0. --- esbuildconf.js | 1 + src/cards.css | 2 -- src/cryptoutils.js | 24 ++++++++++++++ src/main.js | 80 ++++++++++++++++++++++++++++++++++++++-------- src/worker.js | 47 +++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 src/cryptoutils.js create mode 100644 src/worker.js diff --git a/esbuildconf.js b/esbuildconf.js index 8062f34..59e5fe4 100644 --- a/esbuildconf.js +++ b/esbuildconf.js @@ -20,6 +20,7 @@ export const options = { 'src/main.css', 'src/main.js', 'src/manifest.json', + 'src/worker.js', ], outdir: 'dist', //entryNames: '[name]-[hash]', TODO: replace urls in index.html with hashed paths diff --git a/src/cards.css b/src/cards.css index 2a407b4..c3a7a58 100644 --- a/src/cards.css +++ b/src/cards.css @@ -158,6 +158,4 @@ max-width: 48rem; padding: 1.5rem 1.8rem; width: 100%; - /* TODO: revert when things calm down or we find an alternative */ - display: none; } diff --git a/src/cryptoutils.js b/src/cryptoutils.js new file mode 100644 index 0000000..2c2b265 --- /dev/null +++ b/src/cryptoutils.js @@ -0,0 +1,24 @@ +/** + * 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) => { + let count = 0; + for (let i = 0; i < 64; i += 2) { + const hexbyte = hex32.slice(i, i + 2); // grab next byte + 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' ) { + break; // reached non-zero bit; stop + } + count += 1; + } + break; + } + return count; +}; diff --git a/src/main.js b/src/main.js index 4a46852..616d4e0 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,5 @@ import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; +import {zeroLeadingBitsCount} from './cryptoutils'; import {elem, parseTextContent} from './domutil.js'; import {dateTime, formatTime} from './timeutil.js'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ @@ -39,6 +40,10 @@ let pubkey = localStorage.getItem('pub_key') || (() => { return pubkey; })(); +// arbitrary difficulty, still experimenting. +// measured empirically, takes N sec on average to mine a text note event. +const difficulty = 16; + const subList = []; const unSubAll = () => { subList.forEach(sub => sub.unsub()); @@ -160,9 +165,9 @@ function renderProfile(evt, relay) { if (content) { profileAbout.textContent = content.about; profileName.textContent = content.name; - const noxyImg = getNoxyUrl('data', content.picture, evt.id, relay); + const noxyImg = validatePow(evt) && getNoxyUrl('data', content.picture, evt.id, relay); if (noxyImg) { - profileImage.setAttribute('src', getNoxyUrl('data', noxyImg, evt.id, relay)); + profileImage.setAttribute('src', noxyImg); profileImage.hidden = false; } } @@ -439,7 +444,7 @@ function createTextNote(evt, relay) { ]), elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ ...content, - firstLink ? linkPreview(firstLink, evt.id, relay) : '' + (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : '', ]), elem('button', { className: 'btn-inline', name: 'star', type: 'button', @@ -615,7 +620,7 @@ function setMetadata(evt, relay, content) { } } // update profile images - if (user.picture) { + if (user.picture && validatePow(evt)) { document.body .querySelectorAll(`canvas[data-pubkey="${evt.pubkey}"]`) .forEach(canvas => (canvas.parentNode.replaceChild(elem('img', {src: user.picture}), canvas))); @@ -675,7 +680,7 @@ function getMetadata(evt, relay) { const name = user?.metadata[relay]?.name; const userName = name || evt.pubkey.slice(0, 8); const userAbout = user?.metadata[relay]?.about || ''; - const img = userImg ? elem('img', { + const img = (userImg && validatePow(evt)) ? elem('img', { alt: `${userName} ${host}`, loading: 'lazy', src: userImg, @@ -757,13 +762,13 @@ async function upvote(eventId, eventPubkey) { .map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields ['e', eventId], ['p', eventPubkey], // last e and p tag is the id and pubkey of the note being reacted to (nip-25) ]; - const newReaction = { + const newReaction = await powEvent({ kind: 7, pubkey, // TODO: lib could check that this is the pubkey of the key to sign with content: '+', tags, created_at: Math.floor(Date.now() * 0.001), - }; + }, difficulty); const sig = await signEvent(newReaction, privatekey).catch(console.error); if (sig) { const ev = await pool.publish({...newReaction, sig}, (status, url) => { @@ -794,13 +799,13 @@ writeForm.addEventListener('submit', async (e) => { } const replyTo = localStorage.getItem('reply_to'); const tags = replyTo ? [['e', replyTo, eventRelayMap[replyTo][0]]] : []; - const newEvent = { + const newEvent = await powEvent({ kind: 1, - pubkey, content, + pubkey, tags, created_at: Math.floor(Date.now() * 0.001), - }; + }, difficulty); const sig = await signEvent(newEvent, privatekey).catch(onSendError); if (sig) { const ev = await pool.publish({...newEvent, sig}, (status, url) => { @@ -936,13 +941,13 @@ profileForm.addEventListener('submit', async (e) => { const form = new FormData(profileForm); const privatekey = localStorage.getItem('private_key'); - const newProfile = { + const newProfile = await powEvent({ kind: 0, pubkey, content: JSON.stringify(Object.fromEntries(form)), - created_at: Math.floor(Date.now() * 0.001), tags: [], - }; + created_at: Math.floor(Date.now() * 0.001), + }, difficulty); const sig = await signEvent(newProfile, privatekey).catch(console.error); if (sig) { const ev = await pool.publish({...newProfile, sig}, (status, url) => { @@ -957,3 +962,52 @@ profileForm.addEventListener('submit', async (e) => { }).catch(console.error); } }); + +/** + * 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 + * and the updated created_at timestamp. + * + * powEvent returns a rejected promise if the funtion runs for longer than timeout. + * a zero timeout makes mineEvent run without a time limit. + */ +function powEvent(evt, difficulty, timeout) { + return new Promise((resolve, reject) => { + const worker = new Worker('./worker.js'); + + worker.onmessage = (msg) => { + worker.terminate(); + if (msg.data.error) { + reject(msg.data.error); + } else { + resolve(msg.data.event); + } + }; + + worker.onerror = (err) => { + worker.terminate(); + reject(err); + }; + + worker.postMessage({event: evt, difficulty, timeout}); + }); +} diff --git a/src/worker.js b/src/worker.js new file mode 100644 index 0000000..c72967c --- /dev/null +++ b/src/worker.js @@ -0,0 +1,47 @@ +import {getEventHash} from 'nostr-tools'; +import {zeroLeadingBitsCount} from './cryptoutils.js'; + +function mine(event, difficulty, timeout = 5) { + const max = 256; // arbitrary + if (!Number.isInteger(difficulty) || difficulty < 0 || difficulty > max) { + throw new Error(`difficulty must be an integer between 0 and ${max}`); + } + // continue with mining + let n = BigInt(0); + event.tags.unshift(['nonce', n.toString(), `${difficulty}`]); + + const start = Math.floor(Date.now() * 0.001); + console.time('pow'); + while (true) { + const now = Math.floor(Date.now() * 0.001); + // if (now > start + 15) { + // console.timeEnd('pow'); + // return false; + // } + if (now !== event.created_at) { + event.created_at = now; + // n = BigInt(0); // could reset nonce as we have a new timestamp + } + event.tags[0][1] = (++n).toString(); + const id = getEventHash(event); + if (zeroLeadingBitsCount(id) === difficulty) { + console.log(event.tags[0][1], id); + console.timeEnd('pow'); + return event; + } + } +} + +addEventListener('message', async (msg) => { + const { + difficulty, + event, + timeout, + } = msg.data; + try { + const minedEvent = mine(event, difficulty, timeout); + postMessage({event: minedEvent}); + } catch (err) { + postMessage({error: err}); + } +}); \ No newline at end of file -- 2.46.2 From d5e9ef18c7d6e144d6552140234ce5c00872abc2 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Tue, 3 Jan 2023 17:00:35 +0100 Subject: [PATCH 2/5] nip-13: add timeout and show user facing error if it exceeds mining may take a long time if the mining difficulty is high. calculating pow for text notes, upvotes and profile meta data now has a timeout of 10s. if the timeout exceeds a user facing error is shown with the option to try again. the error is currently very basic, and only displays timeout - something went wrong, cancel and try again button. --- src/error.css | 36 +++++++++++++ src/index.html | 1 + src/main.css | 2 + src/main.js | 142 +++++++++++++++++++++++++++++++++---------------- src/tabs.css | 2 +- src/worker.js | 17 +++--- 6 files changed, 141 insertions(+), 59 deletions(-) create mode 100644 src/error.css diff --git a/src/error.css b/src/error.css new file mode 100644 index 0000000..8e24758 --- /dev/null +++ b/src/error.css @@ -0,0 +1,36 @@ +#errorOverlay { + background: var(--bgcolor-danger); + bottom: 0; + display: flex; + flex-direction: column; + left: 0; + overflow: auto; + padding: var(--gap); + position: fixed; + right: 0; + top: 0; + z-index: 100; +} + +.error-title { + margin-top: 0; +} + +#errorOverlay button { + background-color: rgba(0 0 0 / .5); + border: none; + display: inline-block; +} +#errorOverlay button:focus { + outline: 2px solid white; + outline-offset: var(--focus-outline-offset); +} + +#errorOverlay .buttons { + max-width: var(--max-width); +} +@media (orientation: portrait) { + #errorOverlay .buttons { + flex-basis: 4rem; + } +} diff --git a/src/index.html b/src/index.html index 23f0f3e..68246ac 100644 --- a/src/index.html +++ b/src/index.html @@ -106,6 +106,7 @@ + diff --git a/src/main.css b/src/main.css index a60d2f1..01e10b9 100644 --- a/src/main.css +++ b/src/main.css @@ -2,6 +2,7 @@ @import "cards.css"; @import "form.css"; @import "write.css"; +@import "error.css"; :root { /* 5px auto Highlight */ @@ -14,6 +15,7 @@ --focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color); --font-small: 1.2rem; --gap: 2.4rem; + --max-width: 96ch; } ::selection { diff --git a/src/main.js b/src/main.js index 616d4e0..2215efa 100644 --- a/src/main.js +++ b/src/main.js @@ -729,6 +729,9 @@ function appendReplyForm(el) { requestAnimationFrame(() => writeInput.focus()); } +const lockScroll = () => document.body.style.overflow = 'hidden'; +const unlockScroll = () => document.body.style.removeProperty('overflow'); + const newMessageDiv = document.querySelector('#newMessage'); document.querySelector('#bubble').addEventListener('click', (e) => { localStorage.removeItem('reply_to'); // should it forget old replyto context? @@ -738,7 +741,7 @@ document.querySelector('#bubble').addEventListener('click', (e) => { if (writeInput.value.trimRight()) { writeInput.style.removeProperty('height'); } - document.body.style.overflow = 'hidden'; + lockScroll(); requestAnimationFrame(() => updateElemHeight(writeInput)); }); @@ -749,7 +752,7 @@ document.body.addEventListener('keyup', (e) => { }); function hideNewMessage(hide) { - document.body.style.removeProperty('overflow'); + unlockScroll(); newMessageDiv.hidden = hide; } @@ -768,17 +771,19 @@ async function upvote(eventId, eventPubkey) { content: '+', tags, created_at: Math.floor(Date.now() * 0.001), - }, difficulty); - const sig = await signEvent(newReaction, privatekey).catch(console.error); - if (sig) { - const ev = await pool.publish({...newReaction, sig}, (status, url) => { - if (status === 0) { - console.info(`publish request sent to ${url}`); - } - if (status === 1) { - console.info(`event published by ${url}`); - } - }).catch(console.error); + }, difficulty, 10).catch(console.warn); + if (newReaction) { + const sig = await signEvent(newReaction, privatekey).catch(console.error); + if (sig) { + const ev = await pool.publish({...newReaction, sig}, (status, url) => { + if (status === 0) { + console.info(`publish request sent to ${url}`); + } + if (status === 1) { + console.info(`event published by ${url}`); + } + }).catch(console.error); + } } } @@ -805,26 +810,28 @@ writeForm.addEventListener('submit', async (e) => { pubkey, tags, created_at: Math.floor(Date.now() * 0.001), - }, difficulty); - const sig = await signEvent(newEvent, privatekey).catch(onSendError); - if (sig) { - const ev = await pool.publish({...newEvent, sig}, (status, url) => { - if (status === 0) { - console.info(`publish request sent to ${url}`); - } - if (status === 1) { - sendStatus.textContent = ''; - writeInput.value = ''; - writeInput.style.removeProperty('height'); - publish.disabled = true; - if (replyTo) { - localStorage.removeItem('reply_to'); - newMessageDiv.append(writeForm); + }, difficulty, 10).catch(console.warn); + if (newEvent) { + const sig = await signEvent(newEvent, privatekey).catch(onSendError); + if (sig) { + const ev = await pool.publish({...newEvent, sig}, (status, url) => { + if (status === 0) { + console.info(`publish request sent to ${url}`); } - hideNewMessage(true); - // console.info(`event published by ${url}`, ev); - } - }); + if (status === 1) { + sendStatus.textContent = ''; + writeInput.value = ''; + writeInput.style.removeProperty('height'); + publish.disabled = true; + if (replyTo) { + localStorage.removeItem('reply_to'); + newMessageDiv.append(writeForm); + } + hideNewMessage(true); + // console.info(`event published by ${url}`, ev); + } + }); + } } }); @@ -947,22 +954,57 @@ profileForm.addEventListener('submit', async (e) => { content: JSON.stringify(Object.fromEntries(form)), tags: [], created_at: Math.floor(Date.now() * 0.001), - }, difficulty); - const sig = await signEvent(newProfile, privatekey).catch(console.error); - if (sig) { - const ev = await pool.publish({...newProfile, sig}, (status, url) => { - if (status === 0) { - console.info(`publish request sent to ${url}`); - } - if (status === 1) { - profileStatus.textContent = 'profile metadata successfully published'; - profileStatus.hidden = false; - profileSubmit.disabled = true; - } - }).catch(console.error); + }, difficulty, 10).catch(console.warn); + if (newProfile) { + const sig = await signEvent(newProfile, privatekey).catch(console.error); + if (sig) { + const ev = await pool.publish({...newProfile, sig}, (status, url) => { + if (status === 0) { + console.info(`publish request sent to ${url}`); + } + if (status === 1) { + profileStatus.textContent = 'profile metadata successfully published'; + profileStatus.hidden = false; + profileSubmit.disabled = true; + } + }).catch(console.error); + } } }); +const errorOverlay = document.querySelector('#errorOverlay'); + +function promptError(error, options = {}) { + const {onAgain, onCancel} = options; + lockScroll(); + errorOverlay.replaceChildren( + elem('h1', {className: 'error-title'}, error), + elem('p', {}, 'something went wrong'), + elem('div', {className: 'buttons'}, [ + onCancel ? elem('button', {data: {action: 'close'}}, 'close') : '', + onAgain ? elem('button', {data: {action: 'again'}}, 'try again') : '', + ]), + ); + const handleOverlayClick = (e) => { + const button = e.target.closest('button'); + if (button) { + switch(button.dataset.action) { + case 'close': + onCancel(); + break; + case 'again': + onAgain(); + break; + } + errorOverlay.removeEventListener('click', handleOverlayClick); + errorOverlay.hidden = true; + unlockScroll(); + } + }; + errorOverlay.addEventListener('click', handleOverlayClick); + errorOverlay.hidden = false; +} + /** * validate proof-of-work of a nostr event per nip-13. * the validation always requires difficulty commitment in the nonce tag. @@ -990,14 +1032,20 @@ function validatePow(evt) { * powEvent returns a rejected promise if the funtion runs for longer than timeout. * a zero timeout makes mineEvent run without a time limit. */ -function powEvent(evt, difficulty, timeout) { +function powEvent(evt, difficulty, timeout = 0) { return new Promise((resolve, reject) => { const worker = new Worker('./worker.js'); worker.onmessage = (msg) => { worker.terminate(); if (msg.data.error) { - reject(msg.data.error); + promptError(msg.data.error, { + onCancel: () => reject('canceled'), + onAgain: async () => { + const result = await powEvent(evt, difficulty, timeout).catch(console.warn); + resolve(result); + } + }) } else { resolve(msg.data.event); } diff --git a/src/tabs.css b/src/tabs.css index 57a0c07..39cf5ec 100644 --- a/src/tabs.css +++ b/src/tabs.css @@ -43,7 +43,7 @@ input[type="radio"]:checked + label { } .tab-content { - max-width: 96ch; + max-width: var(--max-width); min-height: 200px; padding: calc(.5 * var(--gap)) 0 100px 0; } diff --git a/src/worker.js b/src/worker.js index c72967c..726adb3 100644 --- a/src/worker.js +++ b/src/worker.js @@ -10,14 +10,14 @@ function mine(event, difficulty, timeout = 5) { let n = BigInt(0); event.tags.unshift(['nonce', n.toString(), `${difficulty}`]); - const start = Math.floor(Date.now() * 0.001); + const until = Math.floor(Date.now() * 0.001) + timeout; console.time('pow'); while (true) { const now = Math.floor(Date.now() * 0.001); - // if (now > start + 15) { - // console.timeEnd('pow'); - // return false; - // } + if (timeout !== 0 && (now > until)) { + console.timeEnd('pow'); + throw 'timeout'; + } if (now !== event.created_at) { event.created_at = now; // n = BigInt(0); // could reset nonce as we have a new timestamp @@ -25,7 +25,6 @@ function mine(event, difficulty, timeout = 5) { event.tags[0][1] = (++n).toString(); const id = getEventHash(event); if (zeroLeadingBitsCount(id) === difficulty) { - console.log(event.tags[0][1], id); console.timeEnd('pow'); return event; } @@ -33,11 +32,7 @@ function mine(event, difficulty, timeout = 5) { } addEventListener('message', async (msg) => { - const { - difficulty, - event, - timeout, - } = msg.data; + const {difficulty, event, timeout} = msg.data; try { const minedEvent = mine(event, difficulty, timeout); postMessage({event: minedEvent}); -- 2.46.2 From a5961218218ab956b13a2437c6f16c658a88b610 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Wed, 4 Jan 2023 10:41:23 +0100 Subject: [PATCH 3/5] feed: dirty fix to show replies now that nonce tag is always the first element in the tags list, a bug surfaced that replies from nostrweb did not render anymore. reason was that the code expected the first tag to be an e tag and took its reply-id. this commit is a quick fix that takes the first reply-id from the first e tag. the proper way is a bit more complicated as nip-10 defines a preferred and deprecated way. this is a quick and dirty fix so that replies work with nip-13 pow events, but nip-10 event tags should be properly supported but in a later commit. --- src/main.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.js b/src/main.js index 2215efa..31beb96 100644 --- a/src/main.js +++ b/src/main.js @@ -484,7 +484,7 @@ function handleReply(evt, relay) { } function renderReply(evt, relay) { - const eventId = evt.tags[0][1]; // TODO: double check + const eventId = evt.tags.filter(hasEventTag)[0][1]; // TODO: should check for 'reply' marker with fallback to 'root' marker or last 'e' tag, see nip-10 const article = feedDomMap[eventId] || replyDomMap[eventId]; if (!article) { // root article has not been rendered return; @@ -687,7 +687,7 @@ function getMetadata(evt, relay) { title: `${userName} on ${host} ${userAbout}`, }) : elemCanvas(evt.pubkey); const isReply = evt.tags.some(hasEventTag); - const replies = replyList.filter((reply) => reply.tags[0][1] === evt.id); + const replies = replyList.filter(({tags}) => tags.filter(hasEventTag).some(reply => reply[1] === evt.id)); // TODO: nip-10 const time = new Date(evt.created_at * 1000); return {host, img, isReply, name, replies, time, userName}; } -- 2.46.2 From 898e7265c2e81849bcdbc68839eee0d939f58f55 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Wed, 4 Jan 2023 17:06:48 +0100 Subject: [PATCH 4/5] nip-13: add settings for mining difficulty and timeout adding settings to change mining difficulty and timeout, so users can change or disable pow. also added some explanation and link to nip-13. setting arbitrary low default to 16 zero mining difficulty and 5 seconds timeout. --- src/cards.css | 2 +- src/form.css | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/index.html | 21 +++++++++++++++++++++ src/main.css | 1 + src/main.js | 33 +++++++++++++++++++++++++-------- src/tabs.css | 2 +- 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/cards.css b/src/cards.css index c3a7a58..f389937 100644 --- a/src/cards.css +++ b/src/cards.css @@ -10,7 +10,7 @@ } @media (orientation: portrait) { .mbox { - padding: 0 calc(.5 * var(--gap)); + padding: 0 var(--gap-half); } } .mbox:last-child { diff --git a/src/form.css b/src/form.css index 5a0151b..ac76cda 100644 --- a/src/form.css +++ b/src/form.css @@ -43,6 +43,7 @@ label { transition: background-color var(--transition-duration); } +input[type="number"], input[type="password"], input[type="text"], input[type="url"], @@ -54,6 +55,7 @@ textarea { margin: 0 0 1.2rem 0; padding: var(--padding); } +input[type="number"]:focus, input[type="password"]:focus, input[type="text"]:focus, input[type="url"]:focus, @@ -169,11 +171,13 @@ button:disabled { margin-left: var(--gap); } .form-inline button, +.form-inline input[type="number"], .form-inline input[type="text"], .form-inline textarea { margin: .4rem 0; } +.form-inline input[type="number"], .form-inline input[type="text"], .form-inline textarea { flex-basis: 50%; @@ -187,6 +191,46 @@ button:disabled { flex-grow: 0; } +label.number { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: var(--gap); + padding: 0; +} +* + label.number { + margin: var(--gap) 0 0 0; +} +label.number span { + flex-grow: 1; + padding: 0 0 0 var(--padding); +} +label.number input[type="number"] { + align-self: baseline; + margin-bottom: 0; +} +@media (orientation: landscape) { + label.number span { + align-self: center; + } + label.number input[type="number"] + span { + padding: 0 var(--padding) 0 0; + } +} +@media (orientation: portrait) { + label.number { + flex-direction: column; + gap: var(--gap-half); + padding: 0; + } + label.number span { + padding: 0 var(--padding); + } + label.number input[type="number"] { + align-self: stretch; + } +} + button#publish { align-self: end; order: 2; diff --git a/src/index.html b/src/index.html index 68246ac..31493a0 100644 --- a/src/index.html +++ b/src/index.html @@ -83,6 +83,27 @@ +
+ + +
diff --git a/src/main.css b/src/main.css index 01e10b9..11d7024 100644 --- a/src/main.css +++ b/src/main.css @@ -15,6 +15,7 @@ --focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color); --font-small: 1.2rem; --gap: 2.4rem; + --gap-half: 1.2rem; --max-width: 96ch; } diff --git a/src/main.js b/src/main.js index 31beb96..6cbd997 100644 --- a/src/main.js +++ b/src/main.js @@ -40,10 +40,6 @@ let pubkey = localStorage.getItem('pub_key') || (() => { return pubkey; })(); -// arbitrary difficulty, still experimenting. -// measured empirically, takes N sec on average to mine a text note event. -const difficulty = 16; - const subList = []; const unSubAll = () => { subList.forEach(sub => sub.unsub()); @@ -756,6 +752,23 @@ function hideNewMessage(hide) { newMessageDiv.hidden = hide; } +// arbitrary difficulty default, still experimenting. +let difficulty = JSON.parse(localStorage.getItem('mining_target')) ?? 16; +const miningTargetInput = document.querySelector('#miningTarget'); +miningTargetInput.addEventListener('input', (e) => { + localStorage.setItem('mining_target', miningTargetInput.valueAsNumber); + difficulty = miningTargetInput.valueAsNumber; +}); +miningTargetInput.value = difficulty; + +let timeout = JSON.parse(localStorage.getItem('mining_timeout')) ?? 5; +const miningTimeoutInput = document.querySelector('#miningTimeout'); +miningTimeoutInput.addEventListener('input', (e) => { + localStorage.setItem('mining_timeout', miningTimeoutInput.valueAsNumber); + timeout = miningTimeoutInput.valueAsNumber; +}); +miningTimeoutInput.value = timeout; + async function upvote(eventId, eventPubkey) { const privatekey = localStorage.getItem('private_key'); const note = replyList.find(r => r.id === eventId) || textNoteList.find(n => n.id === (eventId)); @@ -771,7 +784,7 @@ async function upvote(eventId, eventPubkey) { content: '+', tags, created_at: Math.floor(Date.now() * 0.001), - }, difficulty, 10).catch(console.warn); + }, difficulty, timeout).catch(console.warn); if (newReaction) { const sig = await signEvent(newReaction, privatekey).catch(console.error); if (sig) { @@ -810,7 +823,7 @@ writeForm.addEventListener('submit', async (e) => { pubkey, tags, created_at: Math.floor(Date.now() * 0.001), - }, difficulty, 10).catch(console.warn); + }, difficulty, timeout).catch(console.warn); if (newEvent) { const sig = await signEvent(newEvent, privatekey).catch(onSendError); if (sig) { @@ -954,7 +967,7 @@ profileForm.addEventListener('submit', async (e) => { content: JSON.stringify(Object.fromEntries(form)), tags: [], created_at: Math.floor(Date.now() * 0.001), - }, difficulty, 10).catch(console.warn); + }, difficulty, timeout).catch(console.warn); if (newProfile) { const sig = await signEvent(newProfile, privatekey).catch(console.error); if (sig) { @@ -1031,8 +1044,12 @@ function validatePow(evt) { * * powEvent returns a rejected promise if the funtion runs for longer than timeout. * a zero timeout makes mineEvent run without a time limit. + * a zero mining target just resolves the promise without trying to find a 'nonce'. */ -function powEvent(evt, difficulty, timeout = 0) { +function powEvent(evt, difficulty, timeout) { + if (difficulty === 0) { + return Promise.resolve(evt); + } return new Promise((resolve, reject) => { const worker = new Worker('./worker.js'); diff --git a/src/tabs.css b/src/tabs.css index 39cf5ec..9938ecc 100644 --- a/src/tabs.css +++ b/src/tabs.css @@ -45,7 +45,7 @@ input[type="radio"]:checked + label { .tab-content { max-width: var(--max-width); min-height: 200px; - padding: calc(.5 * var(--gap)) 0 100px 0; + padding: var(--gap-half) 0 100px 0; } .tabbed { align-items: start; -- 2.46.2 From 37f0a07cf3bb010a66c750f9c30b0970f24e14cf Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 7 Jan 2023 09:07:42 +0100 Subject: [PATCH 5/5] nip-13: show working msg and cancel btn while mining mining often takes a few seconds. it can be confusing if nothing happens when a user is publishing their profile, upvoting a note or posting a new note. added visual feedback that nostrweb is working with an option to cancel the mining process. --- src/cards.css | 10 +++ src/form.css | 34 +++++----- src/main.js | 171 +++++++++++++++++++++++++++++--------------------- 3 files changed, 124 insertions(+), 91 deletions(-) diff --git a/src/cards.css b/src/cards.css index f389937..255d421 100644 --- a/src/cards.css +++ b/src/cards.css @@ -97,6 +97,16 @@ overflow: visible; position: relative; } +.mbox .buttons { + margin-top: .2rem; +} +.mbox button:not(#publish) { + --bg-color: none; + --border-color: none; +} +.mbox button img + small { + padding-left: .5rem; +} .mobx-replies { flex-grow: 1; position: relative; diff --git a/src/form.css b/src/form.css index ac76cda..562c7a0 100644 --- a/src/form.css +++ b/src/form.css @@ -97,14 +97,22 @@ textarea:focus { align-items: center; display: flex; flex-basis: 100%; - justify-content: flex-end; gap: var(--gap); - margin-top: 2rem; + justify-content: start; + margin-top: var(--gap-half); min-height: 3.2rem; } +form .buttons, +.form .buttons, .form-inline .buttons { flex-basis: fit-content; - margin-top: 0; + justify-content: end; +} + +.buttons img, +.buttons small, +.buttons span { + vertical-align: middle; } button { @@ -123,24 +131,11 @@ button:focus { .btn-inline { --border-color: transparent; - align-items: center; background: transparent; - color: var(--color); - display: inline-flex; - gap: .5ch; + color: var(--color-accent); + display: inline-block; line-height: 1; - padding: .6rem; -} -.btn-inline img { - max-height: 18px; - max-width: 18px; -} -.btn-inline img[alt] { - color: #7f7f7f; - line-height: 1px; -} -.btn-inline img[alt]::before { - font-size: 3.4rem; + padding: 0 .6rem; } .btn-danger { @@ -156,6 +151,7 @@ button:disabled { .form-status { flex-basis: 100%; flex-grow: 1; + min-height: 1.8rem; padding: var(--padding); } diff --git a/src/main.js b/src/main.js index 6cbd997..ad7c22d 100644 --- a/src/main.js +++ b/src/main.js @@ -217,7 +217,7 @@ document.body.addEventListener('click', (e) => { writeInput.blur(); return; } - appendReplyForm(button); + appendReplyForm(button.closest('.buttons')); localStorage.setItem('reply_to', id); return; } @@ -442,26 +442,24 @@ function createTextNote(evt, relay) { ...content, (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : '', ]), - elem('button', { - className: 'btn-inline', name: 'star', type: 'button', - data: {'eventId': evt.id, relay}, - }, [ - elem('img', { - alt: didReact ? '✭' : '✩', // ♥ - height: 24, width: 24, - src: `assets/${didReact ? 'star-fill' : 'star'}.svg`, - title: getReactionList(evt.id).join(' '), - }), - elem('small', {data: {reactions: evt.id}}, hasReactions ? reactionMap[evt.id].length : ''), + elem('div', {className: 'buttons'}, [ + elem('button', {name: 'reply', type: 'button'}, [ + elem('img', {height: 24, width: 24, src: 'assets/comment.svg'}) + ]), + elem('button', {name: 'star', type: 'button'}, [ + elem('img', { + alt: didReact ? '✭' : '✩', // ♥ + height: 24, width: 24, + src: `assets/${didReact ? 'star-fill' : 'star'}.svg`, + title: getReactionList(evt.id).join(' '), + }), + elem('small', {data: {reactions: ''}}, hasReactions ? reactionMap[evt.id].length : ''), + ]), ]), - elem('button', { - className: 'btn-inline', name: 'reply', type: 'button', - data: {'eventId': evt.id, relay}, - }, [elem('img', {height: 24, width: 24, src: 'assets/comment.svg'})]), // replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '', ]); if (restoredReplyTo === evt.id) { - appendReplyForm(body.querySelector('button[name="reply"]')); + appendReplyForm(body.querySelector('.buttons')); requestAnimationFrame(() => updateElemHeight(writeInput)); } return renderArticle([ @@ -770,7 +768,6 @@ miningTimeoutInput.addEventListener('input', (e) => { miningTimeoutInput.value = timeout; async function upvote(eventId, eventPubkey) { - const privatekey = localStorage.getItem('private_key'); const note = replyList.find(r => r.id === eventId) || textNoteList.find(n => n.id === (eventId)); const tags = [ ...note.tags @@ -778,25 +775,35 @@ async function upvote(eventId, eventPubkey) { .map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields ['e', eventId], ['p', eventPubkey], // last e and p tag is the id and pubkey of the note being reacted to (nip-25) ]; + const article = (feedDomMap[eventId] || replyDomMap[eventId]); + const reactionBtn = article.querySelector('[name="star"]'); + const statusElem = article.querySelector('[data-reactions]'); + reactionBtn.disabled = true; const newReaction = await powEvent({ kind: 7, pubkey, // TODO: lib could check that this is the pubkey of the key to sign with content: '+', tags, created_at: Math.floor(Date.now() * 0.001), - }, difficulty, timeout).catch(console.warn); - if (newReaction) { - const sig = await signEvent(newReaction, privatekey).catch(console.error); - if (sig) { - const ev = await pool.publish({...newReaction, sig}, (status, url) => { - if (status === 0) { - console.info(`publish request sent to ${url}`); - } - if (status === 1) { - console.info(`event published by ${url}`); - } - }).catch(console.error); - } + }, {difficulty, statusElem, timeout}).catch(console.warn); + if (!newReaction) { + statusElem.textContent = reactionMap[eventId]?.length; + reactionBtn.disabled = false; + return; + } + const privatekey = localStorage.getItem('private_key'); + const sig = await signEvent(newReaction, privatekey).catch(console.error); + if (sig) { + statusElem.textContent = 'publishing…'; + const ev = await pool.publish({...newReaction, sig}, (status, url) => { + if (status === 0) { + console.info(`publish request sent to ${url}`); + } + if (status === 1) { + console.info(`event published by ${url}`); + } + }).catch(console.error); + reactionBtn.disabled = false; } } @@ -816,6 +823,17 @@ writeForm.addEventListener('submit', async (e) => { return onSendError(new Error('message is empty')); } const replyTo = localStorage.getItem('reply_to'); + const close = () => { + sendStatus.textContent = ''; + writeInput.value = ''; + writeInput.style.removeProperty('height'); + publish.disabled = true; + if (replyTo) { + localStorage.removeItem('reply_to'); + newMessageDiv.append(writeForm); + } + hideNewMessage(true); + }; const tags = replyTo ? [['e', replyTo, eventRelayMap[replyTo][0]]] : []; const newEvent = await powEvent({ kind: 1, @@ -823,28 +841,23 @@ writeForm.addEventListener('submit', async (e) => { pubkey, tags, created_at: Math.floor(Date.now() * 0.001), - }, difficulty, timeout).catch(console.warn); - if (newEvent) { - const sig = await signEvent(newEvent, privatekey).catch(onSendError); - if (sig) { - const ev = await pool.publish({...newEvent, sig}, (status, url) => { - if (status === 0) { - console.info(`publish request sent to ${url}`); - } - if (status === 1) { - sendStatus.textContent = ''; - writeInput.value = ''; - writeInput.style.removeProperty('height'); - publish.disabled = true; - if (replyTo) { - localStorage.removeItem('reply_to'); - newMessageDiv.append(writeForm); - } - hideNewMessage(true); - // console.info(`event published by ${url}`, ev); - } - }); - } + }, {difficulty, statusElem: sendStatus, timeout}).catch(console.warn); + if (!newEvent) { + close(); + return; + } + const sig = await signEvent(newEvent, privatekey).catch(onSendError); + if (sig) { + sendStatus.textContent = 'publishing…'; + const ev = await pool.publish({...newEvent, sig}, (status, url) => { + if (status === 0) { + console.info(`publish request sent to ${url}`); + } + if (status === 1) { + close(); + // console.info(`event published by ${url}`, ev); + } + }); } }); @@ -959,29 +972,31 @@ profileForm.addEventListener('input', (e) => { profileForm.addEventListener('submit', async (e) => { e.preventDefault(); const form = new FormData(profileForm); - const privatekey = localStorage.getItem('private_key'); - const newProfile = await powEvent({ kind: 0, pubkey, content: JSON.stringify(Object.fromEntries(form)), tags: [], created_at: Math.floor(Date.now() * 0.001), - }, difficulty, timeout).catch(console.warn); - if (newProfile) { - const sig = await signEvent(newProfile, privatekey).catch(console.error); - if (sig) { - const ev = await pool.publish({...newProfile, sig}, (status, url) => { - if (status === 0) { - console.info(`publish request sent to ${url}`); - } - if (status === 1) { - profileStatus.textContent = 'profile metadata successfully published'; - profileStatus.hidden = false; - profileSubmit.disabled = true; - } - }).catch(console.error); - } + }, {difficulty, statusElem: profileStatus, timeout}).catch(console.warn); + if (!newProfile) { + profileStatus.textContent = 'publishing profile data canceled'; + profileStatus.hidden = false; + return; + } + const privatekey = localStorage.getItem('private_key'); + const sig = await signEvent(newProfile, privatekey).catch(console.error); + if (sig) { + const ev = await pool.publish({...newProfile, sig}, (status, url) => { + if (status === 0) { + console.info(`publish request sent to ${url}`); + } + if (status === 1) { + profileStatus.textContent = 'profile metadata successfully published'; + profileStatus.hidden = false; + profileSubmit.disabled = true; + } + }).catch(console.error); } }); @@ -1046,20 +1061,31 @@ function validatePow(evt) { * a zero timeout makes mineEvent run without a time limit. * a zero mining target just resolves the promise without trying to find a 'nonce'. */ -function powEvent(evt, difficulty, timeout) { +function powEvent(evt, options) { + const {difficulty, statusElem, timeout} = options; if (difficulty === 0) { return Promise.resolve(evt); } + const cancelBtn = elem('button', {className: 'btn-inline'}, [elem('small', {}, 'cancel')]); + statusElem.replaceChildren('working…', cancelBtn); + statusElem.hidden = false; return new Promise((resolve, reject) => { const worker = new Worker('./worker.js'); + const onCancel = () => { + worker.terminate(); + reject('canceled'); + }; + cancelBtn.addEventListener('click', onCancel); + worker.onmessage = (msg) => { worker.terminate(); + cancelBtn.removeEventListener('click', onCancel); if (msg.data.error) { promptError(msg.data.error, { onCancel: () => reject('canceled'), onAgain: async () => { - const result = await powEvent(evt, difficulty, timeout).catch(console.warn); + const result = await powEvent(evt, {difficulty, statusElem, timeout}).catch(console.warn); resolve(result); } }) @@ -1070,6 +1096,7 @@ function powEvent(evt, difficulty, timeout) { worker.onerror = (err) => { worker.terminate(); + cancelBtn.removeEventListener('click', onCancel); reject(err); }; -- 2.46.2