nip-13: show working msg and cancel btn while mining
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline was successful Details

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.
OFF0 2 years ago
parent 16e5792ff9
commit 825d4b25b4
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -97,6 +97,16 @@
overflow: visible; overflow: visible;
position: relative; 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 { .mobx-replies {
flex-grow: 1; flex-grow: 1;
position: relative; position: relative;

@ -97,14 +97,22 @@ textarea:focus {
align-items: center; align-items: center;
display: flex; display: flex;
flex-basis: 100%; flex-basis: 100%;
justify-content: flex-end;
gap: var(--gap); gap: var(--gap);
margin-top: 2rem; justify-content: start;
margin-top: var(--gap-half);
min-height: 3.2rem; min-height: 3.2rem;
} }
form .buttons,
.form .buttons,
.form-inline .buttons { .form-inline .buttons {
flex-basis: fit-content; flex-basis: fit-content;
margin-top: 0; justify-content: end;
}
.buttons img,
.buttons small,
.buttons span {
vertical-align: middle;
} }
button { button {
@ -123,24 +131,11 @@ button:focus {
.btn-inline { .btn-inline {
--border-color: transparent; --border-color: transparent;
align-items: center;
background: transparent; background: transparent;
color: var(--color); color: var(--color-accent);
display: inline-flex; display: inline-block;
gap: .5ch;
line-height: 1; line-height: 1;
padding: .6rem; padding: 0 .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;
} }
.btn-danger { .btn-danger {
@ -156,6 +151,7 @@ button:disabled {
.form-status { .form-status {
flex-basis: 100%; flex-basis: 100%;
flex-grow: 1; flex-grow: 1;
min-height: 1.8rem;
padding: var(--padding); padding: var(--padding);
} }

@ -217,7 +217,7 @@ document.body.addEventListener('click', (e) => {
writeInput.blur(); writeInput.blur();
return; return;
} }
appendReplyForm(button); appendReplyForm(button.closest('.buttons'));
localStorage.setItem('reply_to', id); localStorage.setItem('reply_to', id);
return; return;
} }
@ -442,26 +442,24 @@ function createTextNote(evt, relay) {
...content, ...content,
(firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : '', (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : '',
]), ]),
elem('button', { elem('div', {className: 'buttons'}, [
className: 'btn-inline', name: 'star', type: 'button', elem('button', {name: 'reply', type: 'button'}, [
data: {'eventId': evt.id, relay}, elem('img', {height: 24, width: 24, src: 'assets/comment.svg'})
}, [ ]),
elem('button', {name: 'star', type: 'button'}, [
elem('img', { elem('img', {
alt: didReact ? '✭' : '✩', // ♥ alt: didReact ? '✭' : '✩', // ♥
height: 24, width: 24, height: 24, width: 24,
src: `assets/${didReact ? 'star-fill' : 'star'}.svg`, src: `assets/${didReact ? 'star-fill' : 'star'}.svg`,
title: getReactionList(evt.id).join(' '), title: getReactionList(evt.id).join(' '),
}), }),
elem('small', {data: {reactions: evt.id}}, hasReactions ? reactionMap[evt.id].length : ''), 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()) : '', // replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '',
]); ]);
if (restoredReplyTo === evt.id) { if (restoredReplyTo === evt.id) {
appendReplyForm(body.querySelector('button[name="reply"]')); appendReplyForm(body.querySelector('.buttons'));
requestAnimationFrame(() => updateElemHeight(writeInput)); requestAnimationFrame(() => updateElemHeight(writeInput));
} }
return renderArticle([ return renderArticle([
@ -770,7 +768,6 @@ miningTimeoutInput.addEventListener('input', (e) => {
miningTimeoutInput.value = timeout; miningTimeoutInput.value = timeout;
async function upvote(eventId, eventPubkey) { 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 note = replyList.find(r => r.id === eventId) || textNoteList.find(n => n.id === (eventId));
const tags = [ const tags = [
...note.tags ...note.tags
@ -778,16 +775,26 @@ async function upvote(eventId, eventPubkey) {
.map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields .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) ['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({ const newReaction = await powEvent({
kind: 7, kind: 7,
pubkey, // TODO: lib could check that this is the pubkey of the key to sign with pubkey, // TODO: lib could check that this is the pubkey of the key to sign with
content: '+', content: '+',
tags, tags,
created_at: Math.floor(Date.now() * 0.001), created_at: Math.floor(Date.now() * 0.001),
}, difficulty, timeout).catch(console.warn); }, {difficulty, statusElem, timeout}).catch(console.warn);
if (newReaction) { 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); const sig = await signEvent(newReaction, privatekey).catch(console.error);
if (sig) { if (sig) {
statusElem.textContent = 'publishing…';
const ev = await pool.publish({...newReaction, sig}, (status, url) => { const ev = await pool.publish({...newReaction, sig}, (status, url) => {
if (status === 0) { if (status === 0) {
console.info(`publish request sent to ${url}`); console.info(`publish request sent to ${url}`);
@ -796,7 +803,7 @@ async function upvote(eventId, eventPubkey) {
console.info(`event published by ${url}`); console.info(`event published by ${url}`);
} }
}).catch(console.error); }).catch(console.error);
} reactionBtn.disabled = false;
} }
} }
@ -816,6 +823,17 @@ writeForm.addEventListener('submit', async (e) => {
return onSendError(new Error('message is empty')); return onSendError(new Error('message is empty'));
} }
const replyTo = localStorage.getItem('reply_to'); 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 tags = replyTo ? [['e', replyTo, eventRelayMap[replyTo][0]]] : [];
const newEvent = await powEvent({ const newEvent = await powEvent({
kind: 1, kind: 1,
@ -823,29 +841,24 @@ writeForm.addEventListener('submit', async (e) => {
pubkey, pubkey,
tags, tags,
created_at: Math.floor(Date.now() * 0.001), created_at: Math.floor(Date.now() * 0.001),
}, difficulty, timeout).catch(console.warn); }, {difficulty, statusElem: sendStatus, timeout}).catch(console.warn);
if (newEvent) { if (!newEvent) {
close();
return;
}
const sig = await signEvent(newEvent, privatekey).catch(onSendError); const sig = await signEvent(newEvent, privatekey).catch(onSendError);
if (sig) { if (sig) {
sendStatus.textContent = 'publishing…';
const ev = await pool.publish({...newEvent, sig}, (status, url) => { const ev = await pool.publish({...newEvent, sig}, (status, url) => {
if (status === 0) { if (status === 0) {
console.info(`publish request sent to ${url}`); console.info(`publish request sent to ${url}`);
} }
if (status === 1) { if (status === 1) {
sendStatus.textContent = ''; close();
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); // console.info(`event published by ${url}`, ev);
} }
}); });
} }
}
}); });
writeInput.addEventListener('input', () => { writeInput.addEventListener('input', () => {
@ -959,16 +972,19 @@ profileForm.addEventListener('input', (e) => {
profileForm.addEventListener('submit', async (e) => { profileForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const form = new FormData(profileForm); const form = new FormData(profileForm);
const privatekey = localStorage.getItem('private_key');
const newProfile = await powEvent({ const newProfile = await powEvent({
kind: 0, kind: 0,
pubkey, pubkey,
content: JSON.stringify(Object.fromEntries(form)), content: JSON.stringify(Object.fromEntries(form)),
tags: [], tags: [],
created_at: Math.floor(Date.now() * 0.001), created_at: Math.floor(Date.now() * 0.001),
}, difficulty, timeout).catch(console.warn); }, {difficulty, statusElem: profileStatus, timeout}).catch(console.warn);
if (newProfile) { 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); const sig = await signEvent(newProfile, privatekey).catch(console.error);
if (sig) { if (sig) {
const ev = await pool.publish({...newProfile, sig}, (status, url) => { const ev = await pool.publish({...newProfile, sig}, (status, url) => {
@ -982,7 +998,6 @@ profileForm.addEventListener('submit', async (e) => {
} }
}).catch(console.error); }).catch(console.error);
} }
}
}); });
const errorOverlay = document.querySelector('#errorOverlay'); const errorOverlay = document.querySelector('#errorOverlay');
@ -1046,20 +1061,31 @@ function validatePow(evt) {
* a zero timeout makes mineEvent run without a time limit. * a zero timeout makes mineEvent run without a time limit.
* a zero difficulty target just resolves the promise without trying to find a 'nonce'. * a zero difficulty 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) { if (difficulty === 0) {
return Promise.resolve(evt); 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) => { return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js'); const worker = new Worker('./worker.js');
const onCancel = () => {
worker.terminate();
reject('canceled');
};
cancelBtn.addEventListener('click', onCancel);
worker.onmessage = (msg) => { worker.onmessage = (msg) => {
worker.terminate(); worker.terminate();
cancelBtn.removeEventListener('click', onCancel);
if (msg.data.error) { if (msg.data.error) {
promptError(msg.data.error, { promptError(msg.data.error, {
onCancel: () => reject('canceled'), onCancel: () => reject('canceled'),
onAgain: async () => { 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); resolve(result);
} }
}) })
@ -1070,6 +1096,7 @@ function powEvent(evt, difficulty, timeout) {
worker.onerror = (err) => { worker.onerror = (err) => {
worker.terminate(); worker.terminate();
cancelBtn.removeEventListener('click', onCancel);
reject(err); reject(err);
}; };

Loading…
Cancel
Save