From 37f0a07cf3bb010a66c750f9c30b0970f24e14cf Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 7 Jan 2023 09:07:42 +0100 Subject: [PATCH] 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); };