|
|
|
@ -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('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: 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()) : '',
|
|
|
|
|
]);
|
|
|
|
|
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});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|