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..255d421 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 { @@ -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; @@ -158,6 +168,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/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/form.css b/src/form.css index 5a0151b..562c7a0 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, @@ -95,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 { @@ -121,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 { @@ -154,6 +151,7 @@ button:disabled { .form-status { flex-basis: 100%; flex-grow: 1; + min-height: 1.8rem; padding: var(--padding); } @@ -169,11 +167,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 +187,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 23f0f3e..31493a0 100644 --- a/src/index.html +++ b/src/index.html @@ -83,6 +83,27 @@ +
+ + +
@@ -106,6 +127,7 @@ + diff --git a/src/main.css b/src/main.css index a60d2f1..11d7024 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,8 @@ --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; } ::selection { diff --git a/src/main.js b/src/main.js index 4a46852..ad7c22d 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/ @@ -160,9 +161,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; } } @@ -216,7 +217,7 @@ document.body.addEventListener('click', (e) => { writeInput.blur(); return; } - appendReplyForm(button); + appendReplyForm(button.closest('.buttons')); localStorage.setItem('reply_to', id); return; } @@ -439,28 +440,26 @@ 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', - 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([ @@ -479,7 +478,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; @@ -615,7 +614,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,14 +674,14 @@ 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, 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}; } @@ -724,6 +723,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? @@ -733,7 +735,7 @@ document.querySelector('#bubble').addEventListener('click', (e) => { if (writeInput.value.trimRight()) { writeInput.style.removeProperty('height'); } - document.body.style.overflow = 'hidden'; + lockScroll(); requestAnimationFrame(() => updateElemHeight(writeInput)); }); @@ -744,12 +746,28 @@ document.body.addEventListener('keyup', (e) => { }); function hideNewMessage(hide) { - document.body.style.removeProperty('overflow'); + unlockScroll(); 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)); const tags = [ ...note.tags @@ -757,15 +775,26 @@ 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 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, 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}`); @@ -774,6 +803,7 @@ async function upvote(eventId, eventPubkey) { console.info(`event published by ${url}`); } }).catch(console.error); + reactionBtn.disabled = false; } } @@ -793,30 +823,38 @@ 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 = { + const newEvent = await powEvent({ kind: 1, - pubkey, content, + pubkey, tags, created_at: Math.floor(Date.now() * 0.001), - }; + }, {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) { - sendStatus.textContent = ''; - writeInput.value = ''; - writeInput.style.removeProperty('height'); - publish.disabled = true; - if (replyTo) { - localStorage.removeItem('reply_to'); - newMessageDiv.append(writeForm); - } - hideNewMessage(true); + close(); // console.info(`event published by ${url}`, ev); } }); @@ -934,15 +972,19 @@ profileForm.addEventListener('input', (e) => { profileForm.addEventListener('submit', async (e) => { e.preventDefault(); 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, 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) => { @@ -957,3 +999,107 @@ profileForm.addEventListener('submit', async (e) => { }).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. + * + * @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. + * a zero mining target just resolves the promise without trying to find a 'nonce'. + */ +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, statusElem, timeout}).catch(console.warn); + resolve(result); + } + }) + } else { + resolve(msg.data.event); + } + }; + + worker.onerror = (err) => { + worker.terminate(); + cancelBtn.removeEventListener('click', onCancel); + reject(err); + }; + + worker.postMessage({event: evt, difficulty, timeout}); + }); +} diff --git a/src/tabs.css b/src/tabs.css index 57a0c07..9938ecc 100644 --- a/src/tabs.css +++ b/src/tabs.css @@ -43,9 +43,9 @@ 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; + padding: var(--gap-half) 0 100px 0; } .tabbed { align-items: start; diff --git a/src/worker.js b/src/worker.js new file mode 100644 index 0000000..726adb3 --- /dev/null +++ b/src/worker.js @@ -0,0 +1,42 @@ +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 until = Math.floor(Date.now() * 0.001) + timeout; + console.time('pow'); + while (true) { + const now = Math.floor(Date.now() * 0.001); + 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 + } + event.tags[0][1] = (++n).toString(); + const id = getEventHash(event); + if (zeroLeadingBitsCount(id) === difficulty) { + 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