From 57be701ef9f5ed21e942d7b402919023cc702a18 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Thu, 2 Feb 2023 09:07:59 +0100 Subject: [PATCH 01/34] routes: use generic view containers proof of concept to use generic view containers instead of specifc functions to show and hide particular views. a view has an identifier (path) which is used to subscribe to relevant data. changing a view updates the history so that browser back displays the last view. each view container has its own scrollbar so that the scrolling position should be preserved when changing back and forth between different views. this change also removes CSS tabs in favor of view or overlays such settings or write a new text note. profile and notes deeplink use now native HTML anchors to improve accessibility (copy/paste, open-in-new-tab, search engines). --- src/cards.css | 22 +- src/error.css | 2 +- src/form.css | 15 +- src/index.html | 223 ++++++++---------- src/main.css | 9 +- src/main.js | 618 ++++++++++++++++++++++++++++--------------------- src/tabs.css | 76 ------ src/view.css | 110 +++++++++ src/write.css | 27 ++- 9 files changed, 602 insertions(+), 500 deletions(-) delete mode 100644 src/tabs.css create mode 100644 src/view.css diff --git a/src/cards.css b/src/cards.css index 006ee5a..b50fbce 100644 --- a/src/cards.css +++ b/src/cards.css @@ -6,14 +6,11 @@ align-items: center; display: flex; flex-direction: row; + flex-shrink: 0; flex-wrap: wrap; margin-bottom: 1rem; - padding: 0 var(--gap); -} -@media (orientation: portrait) { - .mbox { - padding: 0 var(--gap-half); - } + max-width: var(--content-width); + padding: 0 var(--gap-half); } .mbox:last-child { margin-bottom: 0; @@ -28,10 +25,10 @@ border-radius: var(--profileimg-size); flex-basis: var(--profileimg-size); height: var(--profileimg-size); - margin-right: 1.5rem; + margin-right: var(--gap-half); max-height: var(--profileimg-size); max-width: var(--profileimg-size); - overflow: hidden; + overflow: clip; position: relative; z-index: 2; } @@ -53,15 +50,18 @@ word-break: break-word; } .mbox-img + .mbox-body { - flex-basis: calc(100% - 64px - 1rem); + flex-basis: calc(100% - var(--profileimg-size) - var(--gap-half)); } .mbox-header { - flex-basis: calc(100% - 64px - 1rem); + flex-basis: calc(100% - var(--profileimg-size) - var(--gap-half)); flex-grow: 0; flex-shrink: 1; margin-top: 0; } +.mbox-header a { + font-size: var(--font-small); +} .mbox-header time, .mbox-username { color: var(--color-accent); @@ -90,7 +90,7 @@ } .mbox { - overflow: hidden; + overflow: clip; } .mbox .mbox { overflow: visible; diff --git a/src/error.css b/src/error.css index d3cd89a..bd70ad6 100644 --- a/src/error.css +++ b/src/error.css @@ -29,7 +29,7 @@ } #errorOverlay .buttons { - max-width: var(--max-width); + max-width: var(--content-width); } @media (orientation: portrait) { #errorOverlay .buttons { diff --git a/src/form.css b/src/form.css index 200476e..370e027 100644 --- a/src/form.css +++ b/src/form.css @@ -8,7 +8,8 @@ form, --padding: 1.2rem; display: flex; flex-direction: column; - padding: var(--gap); + max-width: var(--content-width); + padding: 0 var(--gap); } fieldset { @@ -21,7 +22,7 @@ legend { display: none; width: 100%; } -#newMessage legend { +#newNote legend { display: block; } @@ -82,17 +83,17 @@ textarea { textarea:focus { min-height: 3.5rem; } -#newMessage textarea { +#newNote textarea { min-height: 10rem; } -#newMessage textarea:focus { +#newNote textarea:focus { min-height: 18rem; } @media (orientation: portrait) { - #newMessage textarea { + #newNote textarea { min-height: 8rem; } - #newMessage textarea:focus { + #newNote textarea:focus { min-height: 15rem; } } @@ -235,7 +236,7 @@ button#publish { button[name="back"] { display: none; } -#newMessage button[name="back"] { +#newNote button[name="back"] { align-self: end; display: inherit; } diff --git a/src/index.html b/src/index.html index 333f00c..4027258 100644 --- a/src/index.html +++ b/src/index.html @@ -2,141 +2,110 @@ - + + + nostr -
- - - - - - -
-
- - - +
+
+
- - + + +
- - - - - - - -
- -
- - - - - - -
- - -
-
-
- - - - -
-
- - - - -
- - - -
-
- -
-
- -
+ + + + + + + diff --git a/src/main.css b/src/main.css index 4d78b6f..66bf78b 100644 --- a/src/main.css +++ b/src/main.css @@ -1,4 +1,4 @@ -@import "tabs.css"; +@import "view.css"; @import "cards.css"; @import "form.css"; @import "write.css"; @@ -16,7 +16,7 @@ --font-small: 1.2rem; --gap: 2.4rem; --gap-half: 1.2rem; - --max-width: 96ch; + --content-width: min(100% - 2.4rem, 96ch); } ::selection { @@ -74,6 +74,11 @@ body { color: var(--color); font-size: 1.6rem; line-height: 1.5; +} + +html, body { + min-height: 100%; + height: 100%; margin: 0; } diff --git a/src/main.js b/src/main.js index e1e6634..6b04424 100644 --- a/src/main.js +++ b/src/main.js @@ -52,39 +52,6 @@ const unSubAll = () => { subList.length = 0; }; -window.addEventListener('popstate', (event) => { - // console.log(`popstate path: ${location.pathname}, state: ${JSON.stringify(event.state)}`); - unSubAll(); - if (event.state?.author) { - subProfile(event.state.author); - return; - } - if (event.state?.pubOrEvt) { - subNoteAndProfile(event.state.pubOrEvt); - return; - } - if (event.state?.eventId) { - subTextNote(event.state.eventId); - return; - } - sub24hFeed(); - showFeed(); -}); - -switch(location.pathname) { - case '/': - history.pushState({}, '', '/'); - sub24hFeed(); - break; - default: - const pubOrEvt = location.pathname.slice(1); - if (pubOrEvt.length === 64 && pubOrEvt.match(/^[0-9a-f]+$/)) { - history.pushState({pubOrEvt}, '', `/${pubOrEvt}`); - subNoteAndProfile(pubOrEvt); - } - break; -} - function sub24hFeed() { subList.push(pool.sub({ cb: onEvent, @@ -92,22 +59,20 @@ function sub24hFeed() { kinds: [0, 1, 2, 7], // until: Math.floor(Date.now() * 0.001), since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)), - limit: 450, + limit: 50, } })); } function subNoteAndProfile(id) { - subProfile(id); + // view(`/${id}`); // assume text note subTextNote(id); + subProfile(id); } function subTextNote(eventId) { subList.push(pool.sub({ - cb: (evt, relay) => { - clearTextNoteDetail(); - showTextNoteDetail(evt, relay); - }, + cb: onEvent, filter: { ids: [eventId], kinds: [1], @@ -119,8 +84,9 @@ function subTextNote(eventId) { function subProfile(pubkey) { subList.push(pool.sub({ cb: (evt, relay) => { - renderProfile(evt, relay); - showProfileDetail(); + console.log('found profile, unsub subTextNote somehow') + // renderProfile(evt, relay); + // view('/[profile]'); }, filter: { authors: [pubkey], @@ -130,136 +96,57 @@ function subProfile(pubkey) { })); // get notes for profile subList.push(pool.sub({ - cb: (evt, relay) => { - showTextNoteDetail(evt, relay); - showProfileDetail(); - }, + cb: onEvent, filter: { authors: [pubkey], kinds: [1], - limit: 450, + limit: 50, } })); } -const detailContainer = document.querySelector('#detail'); -const profileContainer = document.querySelector('#profile'); -const profileAbout = profileContainer.querySelector('.profile-about'); -const profileName = profileContainer.querySelector('.profile-name'); -const profilePubkey = profileContainer.querySelector('.profile-pubkey'); -const profilePubkeyLabel = profileContainer.querySelector('.profile-pubkey-label'); -const profileImage = profileContainer.querySelector('.profile-image'); -const textNoteContainer = document.querySelector('#textnote'); - -function clearProfile() { - profileAbout.textContent = ''; - profileName.textContent = ''; - profilePubkey.textContent = ''; - profilePubkeyLabel.hidden = true; - profileImage.removeAttribute('src'); - profileImage.hidden = true; -} -function renderProfile(evt, relay) { - profileContainer.dataset.pubkey = evt.pubkey; - profilePubkey.textContent = evt.pubkey; - profilePubkeyLabel.hidden = false; - const content = parseContent(evt.content); - if (content) { - profileAbout.textContent = content.about; - profileName.textContent = content.name; - const noxyImg = validatePow(evt) && getNoxyUrl('data', content.picture, evt.id, relay); - if (noxyImg) { - profileImage.setAttribute('src', noxyImg); - profileImage.hidden = false; - } - } -} - -function showProfileDetail() { - profileContainer.hidden = false; - textNoteContainer.hidden = false; - showDetail(); -} - -function clearTextNoteDetail() { - textNoteContainer.replaceChildren([]); -} - -function showTextNoteDetail(evt, relay) { - if (!textNoteContainer.querySelector(`[data-id="${evt.id}"]`)) { - textNoteContainer.append(createTextNote(evt, relay)); - } - textNoteContainer.hidden = false; - profileContainer.hidden = true; - showDetail(); -} - -function showDetail() { - feedContainer.hidden = true; - detailContainer.hidden = false; -} - -function showFeed() { - feedContainer.hidden = false; - detailContainer.hidden = true; -} - -document.querySelector('label[for="feed"]').addEventListener('click', () => { - if (location.pathname !== '/') { - showFeed(); - history.pushState({}, '', '/'); - unSubAll(); - sub24hFeed(); - } -}); - -document.body.addEventListener('click', (e) => { - const button = e.target.closest('button'); - const pubkey = e.target.closest('[data-pubkey]')?.dataset.pubkey; - const id = e.target.closest('[data-id]')?.dataset.id; - const relay = e.target.closest('[data-relay]')?.dataset.relay; - if (button && button.name === 'reply') { - if (localStorage.getItem('reply_to') === id) { - writeInput.blur(); - return; - } - appendReplyForm(button.closest('.buttons')); - localStorage.setItem('reply_to', id); - return; - } - if (button && button.name === 'star') { - upvote(id, pubkey); - return; - } - if (button && button.name === 'back') { - hideNewMessage(true); - return; - } - const username = e.target.closest('.mbox-username') - if (username) { - history.pushState({author: pubkey}, '', `/${pubkey}`); - unSubAll(); - clearProfile(); - clearTextNoteDetail(); - subProfile(pubkey); - showProfileDetail(); - return; - } - const eventTime = e.target.closest('.mbox-header time'); - if (eventTime) { - history.pushState({eventId: id, relay}, '', `/${id}`); - unSubAll(); - clearTextNoteDetail(); - subTextNote(id); - return; - } - // const container = e.target.closest('[data-append]'); - // if (container) { - // container.append(...parseTextContent(container.dataset.append)); - // delete container.dataset.append; - // return; +const containers = [ + // { + // id: '/00527c2b28ea78446c148cb40cc6e442ea3d0945ff5a8b71076483398525b54d', + // view: Node, + // content: Node, + // dom: {} // } -}); +]; +let activeContainerIndex = null; + +// const profileContainer = document.querySelector('#profile'); +// const profileAbout = profileContainer.querySelector('.profile-about'); +// const profileName = profileContainer.querySelector('.profile-name'); +// const profilePubkey = profileContainer.querySelector('.profile-pubkey'); +// const profilePubkeyLabel = profileContainer.querySelector('.profile-pubkey-label'); +// const profileImage = profileContainer.querySelector('.profile-image'); +// const textNoteContainer = document.querySelector('#textnote'); + +// function clearProfile() { +// profileAbout.textContent = ''; +// profileName.textContent = ''; +// profilePubkey.textContent = ''; +// profilePubkeyLabel.hidden = true; +// profileImage.removeAttribute('src'); +// profileImage.hidden = true; +// } +// function renderProfile(evt, relay) { +// profileContainer.dataset.pubkey = evt.pubkey; +// profilePubkey.textContent = evt.pubkey; +// profilePubkeyLabel.hidden = false; +// const content = parseContent(evt.content); +// if (content) { +// profileAbout.textContent = content.about; +// profileName.textContent = content.name; +// const noxyImg = validatePow(evt) && getNoxyUrl('data', content.picture, evt.id, relay); +// if (noxyImg) { +// profileImage.setAttribute('src', noxyImg); +// profileImage.hidden = false; +// } +// } +// } + const textNoteList = []; // could use indexDB const eventRelayMap = {}; // eventId: [relay1, relay2] @@ -267,33 +154,33 @@ const eventRelayMap = {}; // eventId: [relay1, relay2] const hasEventTag = tag => tag[0] === 'e'; const isReply = ([tag, , , marker]) => tag === 'e' && marker !== 'mention'; const isMention = ([tag, , , marker]) => tag === 'e' && marker === 'mention'; +const hasEnoughPOW = ([tag, , commitment]) => { + return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(note.id) >= fitlerDifficulty; +}; +const renderNote = (evt, i, sortedFeeds) => { + if (getViewElem(evt.id)) { // note already in view + return; + } + const article = createTextNote(evt, eventRelayMap[evt.id]); + if (i === 0) { + getViewContent().append(article); + } else { + getViewElem(sortedFeeds[i - 1].id).before(article); + } + setViewElem(evt.id, article); +}; const renderFeed = bounce(() => { const now = Math.floor(Date.now() * 0.001); - const sortedFeeds = textNoteList + textNoteList // dont render notes from the future .filter(note => note.created_at <= now) // if difficulty filter is configured dont render notes with too little pow - .filter(note => { - return !fitlerDifficulty || note.tags.some(([tag, , commitment]) => { - return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(note.id) >= fitlerDifficulty; - }); - }) - .sort(sortByCreatedAt).reverse(); - sortedFeeds.forEach((evt, i) => { - if (feedDomMap[evt.id]) { - // TODO check eventRelayMap if event was published to different relays - return; - } - const article = createTextNote(evt, eventRelayMap[evt.id]); - if (i === 0) { - feedContainer.append(article); - } else { - feedDomMap[sortedFeeds[i - 1].id].before(article); - } - feedDomMap[evt.id] = article; - }); -}, 17); // (16.666 rounded, a bit arbitrary but that it doesnt update more than 60x per s) + .filter(note => !fitlerDifficulty || note.tags.some(hasEnoughPOW)) + .sort(sortByCreatedAt) + .reverse() + .forEach(renderNote); +}, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s) function handleTextNote(evt, relay) { if (eventRelayMap[evt.id]) { @@ -305,29 +192,40 @@ function handleTextNote(evt, relay) { } else { textNoteList.push(evt); } + } + if (!getViewElem(evt.id)) { renderFeed(); } } const replyList = []; -const replyDomMap = {}; -const replyToMap = {}; function handleReply(evt, relay) { if ( - replyDomMap[evt.id] // already rendered probably received from another relay + getViewElem(evt.id) // already rendered probably received from another relay || evt.tags.some(isMention) // ignore mentions for now ) { return; } - if (!replyToMap[evt.id]) { - replyToMap[evt.id] = getReplyTo(evt); + const replyTo = getReplyTo(evt); + const evtWithReplyTo = {replyTo, ...evt}; + replyList.push(evtWithReplyTo); + renderReply(evtWithReplyTo, relay); +} + +function renderReply(evt, relay) { + const parent = getViewElem(evt.replyTo); + if (!parent) { // root article has not been rendered + return; } - replyList.push({ - replyTo: replyToMap[evt.id], - ...evt, - }); - renderReply(evt, relay); + let replyContainer = parent.querySelector('.mobx-replies'); + if (!replyContainer) { + replyContainer = elem('div', {className: 'mobx-replies'}); + parent.append(replyContainer); + } + const reply = createTextNote(evt, relay); + replyContainer.append(reply); + setViewElem(evt.id, reply); } const reactionMap = {}; @@ -353,22 +251,19 @@ function handleReaction(evt, relay) { } else { reactionMap[eventId] = [evt]; } - const article = feedDomMap[eventId] || replyDomMap[eventId]; + const article = getViewElem(eventId); if (article) { const button = article.querySelector('button[name="star"]'); const reactions = button.querySelector('[data-reactions]'); reactions.textContent = reactionMap[eventId].length; if (evt.pubkey === pubkey) { const star = button.querySelector('img[src*="star"]'); - star?.setAttribute('src', 'assets/star-fill.svg'); + star?.setAttribute('src', '/assets/star-fill.svg'); star?.setAttribute('title', getReactionList(eventId).join(' ')); } } } -// feed -const feedContainer = document.querySelector('#homefeed'); -const feedDomMap = {}; const restoredReplyTo = localStorage.getItem('reply_to'); const sortByCreatedAt = (evt1, evt2) => { @@ -379,9 +274,9 @@ const sortByCreatedAt = (evt1, evt2) => { }; function rerenderFeed() { - Object.keys(feedDomMap).forEach(key => delete feedDomMap[key]); - Object.keys(replyDomMap).forEach(key => delete replyDomMap[key]); - feedContainer.replaceChildren([]); + const domMap = getViewDom(); // TODO: this is only the current view, do this for all views + Object.keys(domMap).forEach(key => delete domMap[key]); + getViewContent().replaceChildren([]); renderFeed(); } @@ -468,7 +363,7 @@ function createTextNote(evt, relay) { // const content = isLongContent ? evt.content.slice(0, 280) : evt.content; const hasReactions = reactionMap[evt.id]?.length > 0; const didReact = hasReactions && !!reactionMap[evt.id].find(reaction => reaction.pubkey === pubkey); - const replyFeed = replies[0] ? replies.sort(sortByCreatedAt).map(e => replyDomMap[e.id] = createTextNote(e, relay)) : []; + const replyFeed = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : []; const [content, {firstLink}] = parseTextContent(evt.content); const body = elem('div', {className: 'mbox-body'}, [ elem('header', { @@ -477,11 +372,9 @@ function createTextNote(evt, relay) { ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} ${evt.content}` }, [ - elem('small', {}, [ - elem('strong', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`}, name || userName), - ' ', - elem('time', {dateTime: time.toISOString()}, formatTime(time)), - ]), + elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.pubkey}`, data: {nav: '/[profile]'}}, name || userName), + ' ', + elem('a', {href: `/${evt.id}`, data: {nav: '/[note]'}}, formatTime(time)), ]), elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ ...content, @@ -489,13 +382,13 @@ function createTextNote(evt, relay) { ]), elem('div', {className: 'buttons'}, [ elem('button', {name: 'reply', type: 'button'}, [ - elem('img', {height: 24, width: 24, src: 'assets/comment.svg'}) + 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`, + src: `/assets/${didReact ? 'star-fill' : 'star'}.svg`, title: getReactionList(evt.id).join(' '), }), elem('small', {data: {reactions: ''}}, hasReactions ? reactionMap[evt.id].length : ''), @@ -512,22 +405,6 @@ function createTextNote(evt, relay) { ], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); } -function renderReply(evt, relay) { - const replyToId = replyToMap[evt.id]; - const article = feedDomMap[replyToId] || replyDomMap[replyToId]; - if (!article) { // root article has not been rendered - return; - } - let replyContainer = article.querySelector('.mobx-replies'); - if (!replyContainer) { - replyContainer = elem('div', {className: 'mobx-replies'}); - article.append(replyContainer); - } - const reply = createTextNote(evt, relay); - replyContainer.append(reply); - replyDomMap[evt.id] = reply; -} - const sortEventCreatedAt = (created_at) => ( {created_at: a}, {created_at: b}, @@ -544,33 +421,33 @@ function isWssUrl(string) { } function handleRecommendServer(evt, relay) { - if (feedDomMap[evt.id] || !isWssUrl(evt.content)) { + if (getViewElem(evt.id) || !isWssUrl(evt.content)) { return; } const art = renderRecommendServer(evt, relay); if (textNoteList.length < 2) { - feedContainer.append(art); + getViewContent().append(art); } else { const closestTextNotes = textNoteList .filter(note => !fitlerDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && commitment >= fitlerDifficulty)) .sort(sortEventCreatedAt(evt.created_at)); - feedDomMap[closestTextNotes[0].id]?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed + getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed } - feedDomMap[evt.id] = art; + setViewElem(evt.id, art); } function handleContactList(evt, relay) { - if (feedDomMap[evt.id]) { + if (getViewElem(evt.id)) { return; } const art = renderUpdateContact(evt, relay); if (textNoteList.length < 2) { - feedContainer.append(art); + getViewContent().append(art); return; } const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); - feedDomMap[closestTextNotes[0].id].after(art); - feedDomMap[evt.id] = art; + getViewElem(closestTextNotes[0].id).after(art); + setViewElem(evt.id, art); // const user = userList.find(u => u.pupkey === evt.pubkey); // if (user) { // console.log(`TODO: add contact list for ${evt.pubkey.slice(0, 8)} on ${relay}`, evt.tags); @@ -585,9 +462,7 @@ function renderUpdateContact(evt, relay) { const {img, time, userName} = getMetadata(evt, relay); const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ elem('header', {className: 'mbox-header'}, [ - elem('small', {}, [ - - ]), + elem('small', {}, []), ]), elem('pre', {title: JSON.stringify(evt.content)}, [ elem('strong', {}, userName), @@ -725,9 +600,8 @@ function getMetadata(evt, relay) { src: userImg, title: `${userName} on ${host} ${userAbout}`, }) : elemCanvas(evt.pubkey); - const isReply = !!replyToMap[evt.id]; const time = new Date(evt.created_at * 1000); - return {host, img, isReply, name, time, userName}; + return {host, img, name, time, userName}; } /** @@ -790,30 +664,6 @@ function appendReplyForm(el) { 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? - newMessageDiv.prepend(writeForm); - hideNewMessage(false); - writeInput.focus(); - if (writeInput.value.trimRight()) { - writeInput.style.removeProperty('height'); - } - lockScroll(); - requestAnimationFrame(() => updateElemHeight(writeInput)); -}); - -document.body.addEventListener('keyup', (e) => { - if (e.key === 'Escape') { - hideNewMessage(true); - } -}); - -function hideNewMessage(hide) { - unlockScroll(); - newMessageDiv.hidden = hide; -} - let fitlerDifficulty = JSON.parse(localStorage.getItem('filter_difficulty')) ?? 0; const filterDifficultyInput = document.querySelector('#filterDifficulty'); const filterDifficultyDisplay = document.querySelector('[data-display="filter_difficulty"]'); @@ -851,7 +701,7 @@ 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 article = getViewElem(eventId); const reactionBtn = article.querySelector('[name="star"]'); const statusElem = article.querySelector('[data-reactions]'); reactionBtn.disabled = true; @@ -906,11 +756,11 @@ writeForm.addEventListener('submit', async (e) => { publish.disabled = true; if (replyTo) { localStorage.removeItem('reply_to'); - newMessageDiv.append(writeForm); + publishView.append(writeForm); } - hideNewMessage(true); + publishView.hidden = true; }; - const tags = replyTo ? [['e', replyTo, eventRelayMap[replyTo][0]]] : []; + const tags = replyTo ? [['e', replyTo]] : []; // , eventRelayMap[replyTo][0] const newEvent = await powEvent({ kind: 1, content, @@ -954,6 +804,239 @@ function updateElemHeight(el) { } } + + + + + + + + + + +function getViewContent() { + return containers[activeContainerIndex]?.content; +} + +function getViewDom() { + return containers[activeContainerIndex]?.dom; +} + +function getViewElem(key) { + return containers[activeContainerIndex]?.dom[key]; +} + +function setViewElem(key, node) { + const container = containers[activeContainerIndex]; + if (container) { + container.dom[key] = node; + } + return node; +} + +const mainContainer = document.querySelector('main'); + +const getContainer = (containers, route) => { + let container = containers.find(c => c.route === route); + if (container) { + return container; + } + const content = elem('div', {className: 'content'}); + const view = elem('section', {className: 'view'}, [content]); + mainContainer.append(view); + container = {route, view, content, dom: {}}; + containers.push(container); + return container; +}; + +document.body.onload = () => console.log('------------ pageload ------------') + +function view(route) { + const active = containers[activeContainerIndex]; + active?.view.classList.remove('view-active'); + const nextContainer = getContainer(containers, route); + const nextContainerIndex = containers.indexOf(nextContainer); + if (nextContainerIndex === activeContainerIndex) { + return; + } + if (active) { + nextContainer.view.classList.add('view-next'); + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + nextContainer.view.classList.remove('view-next', 'view-prev'); + nextContainer.view.classList.add('view-active'); + }); + // // console.log(activeContainerIndex, nextContainerIndex); + getViewContent()?.querySelectorAll('.view-prev').forEach(prev => { + prev.classList.remove('view-prev'); + prev.classList.add('view-next'); + }); + active?.view.classList.add(nextContainerIndex < activeContainerIndex ? 'view-next' : 'view-prev'); + activeContainerIndex = nextContainerIndex; + }); +} + +function navigate(route) { + if (typeof route === 'string') { + view(route); + history.pushState({}, '', route); + return; + } + if (route.pubkey) { + view(`/${route.pubkey}`); + history.pushState(route, '', `/${route.pubkey}`); + return; + } + if (route.note) { + view(`/${route.note}`); + history.pushState(route, '', `/${route.note}`); + return; + } + if (route.pubOrNote) { + view(`/${route.pubOrNote}`); + history.pushState(route, '', `/${route.pubOrNote}`); + return; + } + console.warn('unhandeleded', route); +} + +// onload +switch(location.pathname) { + case '/': + sub24hFeed(); + navigate('/'); + break; + default: + const pubOrNote = location.pathname.slice(1); + if (pubOrNote.length === 64 && pubOrNote.match(/^[0-9a-f]+$/)) { + navigate({pubOrNote}); + subNoteAndProfile(pubOrNote); + } + break; +} + +window.addEventListener('popstate', (event) => { + // console.log(`popstate: ${location.pathname}, state: ${JSON.stringify(event.state)}`); + unSubAll(); + if (event.state?.pubkey) { + subProfile(event.state.pubkey); + view(`/${event.state.pubkey}`); + return; + } + if (event.state?.pubOrNote) { + subNoteAndProfile(event.state.pubOrNote); + view(`/${event.state.pubOrNote}`); // assuming note + return; + } + if (event.state?.note) { + subTextNote(event.state.note); + view(`/${event.state.note}`); // assuming note + return; + } + if (location.pathname === '/') { + sub24hFeed(); + view('/'); + return; + } +}); + +const settingsView = document.querySelector('#settings'); +const publishView = document.querySelector('#newNote'); + +document.body.addEventListener('click', (e) => { + const a = e.target.closest('a'); + const pubkey = e.target.closest('[data-pubkey]')?.dataset.pubkey; + const id = e.target.closest('[data-id]')?.dataset.id; + if (a) { + if ('nav' in a.dataset) { + e.preventDefault(); + if (!settingsView.hidden) { + settingsView.hidden = true; + } + if (!publishView.hidden) { + publishView.hidden = true; + } + const href = a.getAttribute('href'); + switch(href) { + case '/': + navigate('/'); + unSubAll(); + sub24hFeed(); + break; + default: + switch(a.dataset.nav) { + case '/[profile]': + unSubAll(); + subProfile(pubkey); + navigate({pubkey}); + break; + case '/[note]': + unSubAll(); + subTextNote(id) + navigate({note: id}); + break; + default: + console.warn('what route is that', href); + } + break; + } + e.preventDefault(); + } + return; + } + const button = e.target.closest('button'); + if (button) { + switch(button.name) { + case 'reply': + if (localStorage.getItem('reply_to') === id) { + writeInput.blur(); + return; + } + appendReplyForm(button.closest('.buttons')); + localStorage.setItem('reply_to', id); + break; + case 'star': + upvote(id, pubkey); + break; + case 'settings': + settingsView.hidden = !settingsView.hidden; + break; + case 'new-note': + if (publishView.hidden) { + localStorage.removeItem('reply_to'); // should it forget old replyto context? + publishView.append(writeForm); + if (writeInput.value.trimRight()) { + writeInput.style.removeProperty('height'); + } + requestAnimationFrame(() => { + updateElemHeight(writeInput); + writeInput.focus(); + }); + publishView.removeAttribute('hidden'); + } else { + publishView.hidden = true; + } + break; + case 'back': + publishView.hidden = true; + break; + } + } + // const container = e.target.closest('[data-append]'); + // if (container) { + // container.append(...parseTextContent(container.dataset.append)); + // delete container.dataset.append; + // return; + // } +}); + +// document.body.addEventListener('keyup', (e) => { +// if (e.key === 'Escape') { +// hideNewMessage(true); +// } +// }); + // settings const settingsForm = document.querySelector('form[name="settings"]'); const privateKeyInput = settingsForm.querySelector('#privatekey'); @@ -1172,6 +1255,7 @@ function powEvent(evt, options) { worker.onerror = (err) => { worker.terminate(); + // promptError(msg.data.error, {}); cancelBtn.removeEventListener('click', onCancel); reject(err); }; diff --git a/src/tabs.css b/src/tabs.css deleted file mode 100644 index 9938ecc..0000000 --- a/src/tabs.css +++ /dev/null @@ -1,76 +0,0 @@ -.tabs { - flex-basis: 100%; - margin-top: 4rem; -} -.tabs .tab-content { display: none; } -#feed:checked ~ .tabs .tab-content:nth-child(1), -#trending:checked ~ .tabs .tab-content:nth-child(2), -#direct:checked ~ .tabs .tab-content:nth-child(3), -#chat:checked ~ .tabs .tab-content:nth-child(4), -#settings:checked ~ .tabs .tab-content:nth-child(5) { display: block; } - -input[type="radio"].tab { - clip: rect(0, 0, 0, 0); - height: 0; - overflow: hidden; - position: absolute; - width: 0; -} - -.tab + label { - background-color: var(--bgcolor-textinput); - border: none; - color: var(--color); - display: inline-block; - margin-left: var(--gap); - margin-top: var(--gap); - outline: 2px solid var(--bgcolor-accent); - padding: 1rem 1.5em; - position: relative; - top: 1px; - z-index: 11; -} -input[type="radio"]:checked + label { - background: var(--bgcolor-accent); -} - -.tab:focus + label, -.tab:active + label { - border-color: var(--focus-border-color); - border-radius: var(--focus-border-radius); - outline: var(--focus-outline); - outline-offset: var(--focus-outline-offset); -} - -.tab-content { - max-width: var(--max-width); - min-height: 200px; - padding: var(--gap-half) 0 100px 0; -} -.tabbed { - align-items: start; - display: flex; - flex-wrap: wrap; -} -@media (orientation: portrait) { - .tabbed { - align-items: start; - display: flex; - flex-direction: row-reverse; - flex-wrap: wrap; - justify-content: start; - } - .tabs { - height: 100vh; - height: 100dvh; - margin-top: 0; - order: 1; - overflow: scroll; - width: 100vw; - } - .tab + label { - margin-top: calc(-3 * var(--gap)); - margin-left: var(--gap); - order: 2; - } -} \ No newline at end of file diff --git a/src/view.css b/src/view.css new file mode 100644 index 0000000..beefa1f --- /dev/null +++ b/src/view.css @@ -0,0 +1,110 @@ +.root { + display: flex; + height: 100%; + max-height: 100%; + flex-direction: column; +} +@media (orientation: landscape) { + .root { + flex-direction: row-reverse; + } +} + +main { + display: flex; + flex-grow: 1; + height: 100%; + overflow: clip; + position: relative; + width: 100%; +} + +aside { + order: 2; +} + +nav { + background-color: indigo; + display: flex; + flex-direction: row; + flex-grow: 1; + flex-shrink: 0; + justify-content: space-around; + overflow-y: auto; + padding: 1rem 1.5rem; + user-select: none; + -webkit-user-select: none; +} +@supports (padding: max(0px)) { + nav { + padding-bottom: env(safe-area-inset-bottom); + } +} +@media (orientation: landscape) { + nav { + flex-direction: column; + justify-content: space-between; + } +} + +.view { + background-color: var(--bgcolor); + display: flex; + flex-direction: column; + left: 0; + min-height: 100%; + opacity: 1; + overflow-x: clip; + position: absolute; + top: 0; + transform: translateX(0); + transition: transform .3s cubic-bezier(.465,.183,.153,.946); + width: 100%; + will-change: transform; +} +@media (orientation: landscape) { + .view { + transition: opacity .3s cubic-bezier(.465,.183,.153,.946); + } +} +@media (orientation: portrait) { + .view.view-next { + transform: translateX(100%); + } + .view.view-prev { + position: relative; + transform: translateX(-20%); + z-index: 0; + } +} +@media (orientation: landscape) { + .view.view-next, + .view.view-next { + opacity: 0; + pointer-events: none; + } +} + +.content { + display: flex; + flex-direction: column; + flex-grow: 1; + margin-inline: auto; + overflow-y: auto; + padding: var(--gap-half) 0; + width: 100%; +} +main .content { + height: 1px; +} +nav .content { + display: flex; + flex-direction: row; + justify-content: space-between; +} +nav a { + display: flex; + flex-direction: column; + text-align: center; + text-decoration: none; +} diff --git a/src/write.css b/src/write.css index d185f56..ab46ef4 100644 --- a/src/write.css +++ b/src/write.css @@ -1,20 +1,29 @@ #bubble { - bottom: 4rem; + background-color: darkmagenta; + border-color: darkmagenta; + border-radius: 10rem; + bottom: 8rem; height: 10rem; padding: 0; position: fixed; - right: 5rem; + right: 8rem; width: 10rem; - z-index: 12; + z-index: 1; } @media (orientation: portrait) { #bubble { - bottom: calc(2 * var(--gap)); + bottom: calc(4 * var(--gap)); right: var(--gap); } } +#bubble svg { + height: 100%; + position: relative; + width: 100%; + top: .5rem; +} -#newMessage { +#newNote { align-items: center; display: flex; height: 100vh; @@ -25,12 +34,12 @@ z-index: 20; } @media (orientation: portrait) { - #newMessage { + #newNote { align-items: start; } } -#newMessage #writeForm { +#newNote #writeForm { align-items: start; background-color: var(--bgcolor); display: flex; @@ -46,11 +55,11 @@ padding: 2rem; } -#newMessage .form-inline textarea { +#newNote .form-inline textarea { flex-basis: 100%; margin: var(--gap) 0; } -#newMessage .buttons { +#newNote .buttons { align-self: end; } \ No newline at end of file -- 2.46.2 From 87cd5f21b36bedbc89acf07640f334463a68fa02 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 18 Feb 2023 11:19:15 +0100 Subject: [PATCH 02/34] relays: type and upgrade to nostr-tools@1.6.0 move and typed relay related code to relays.ts upgrade nostr-tools to latest greatest, major version with breaking changes: - relayPool is gone in favor of SimplePool, but this commit just used relayInit directly as relays should become configurable at some point --- package-lock.json | 1267 ++++++--------------------------------------- package.json | 5 +- src/main.js | 142 ++--- src/relays.ts | 99 ++++ src/worker.js | 2 +- 5 files changed, 304 insertions(+), 1211 deletions(-) create mode 100644 src/relays.ts diff --git a/package-lock.json b/package-lock.json index 50ee5c2..a26317c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,21 @@ { "name": "nostrweb", "version": "0.0.24", - "lockfileVersion": 2, + "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nostrweb", "version": "0.0.24", + "dependencies": { + "nostr-tools": "1.6.0" + }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.1.1", "esbuild": "^0.14.54", "esbuild-plugin-alias": "^0.2.1", "events": "^3.3.0", - "nostr-tools": "0.24.1" + "readable-stream": "4.3.0" } }, "node_modules/@esbuild-plugins/node-globals-polyfill": { @@ -40,17 +43,104 @@ "node": ">=12" } }, + "node_modules/@noble/curves": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.0.0.tgz", + "integrity": "sha512-2upgEu0iLiDVDZkNLeFV2+ht0BAVgQnEmCk6JsOch9Rp8xfkMCbvbAZlA2pBHQc73dbl+vFOXfqkf4uemdn0bw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "@noble/hashes": "1.3.0" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz", + "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, "node_modules/@noble/hashes": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-0.5.9.tgz", - "integrity": "sha512-7lN1Qh6d8DUGmfN36XRsbN/WcGIPNtTGhkw26vWId/DlCIGsYJJootTtPGghTLcn/AaXPx2Q0b3cacrwXa7OVw==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.0.0.tgz", + "integrity": "sha512-DZVbtY62kc3kkBtMHqwCOfXrT/hnoORy5BJ4+HU1IR59X0KWAOqsfzQPcUl/lQLlG7qXbe/fZ3r/emxtAl+sqg==" }, "node_modules/@noble/secp256k1": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.0.tgz", - "integrity": "sha512-kbacwGSsH/CTout0ZnZWxnW1B+jH/7r/WAAKLBtrRJ/+CUH7lgmQzl3GTrQua3SGKWNSDsS6lmjnDpIJ5Dxyaw==", - "dev": true, + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.1.tgz", + "integrity": "sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@scure/bip32": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.0.tgz", + "integrity": "sha512-bcKpo1oj54hGholplGLpqPHRbIsnbixFtc06nwuNM5/dwSXOq/AAYoIBRsBmnZJSdfeNW5rnff7NTAz3ZCqR9Q==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "@noble/curves": "~1.0.0", + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz", + "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@scure/bip39": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.0.tgz", + "integrity": "sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz", + "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==", "funding": [ { "type": "individual", @@ -58,6 +148,18 @@ } ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -78,43 +180,6 @@ } ] }, - "node_modules/browserify-aes": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", - "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", - "dev": true, - "dependencies": { - "buffer-xor": "^1.0.3", - "cipher-base": "^1.0.0", - "create-hash": "^1.1.0", - "evp_bytestokey": "^1.0.3", - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/browserify-cipher": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", - "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", - "dev": true, - "dependencies": { - "browserify-aes": "^1.0.4", - "browserify-des": "^1.0.0", - "evp_bytestokey": "^1.0.0" - } - }, - "node_modules/browserify-des": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", - "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", - "dev": true, - "dependencies": { - "cipher-base": "^1.0.1", - "des.js": "^1.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -139,122 +204,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/buffer-xor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", - "dev": true - }, - "node_modules/bufferutil": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz", - "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/cipher-base": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", - "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/create-hash": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", - "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", - "dev": true, - "dependencies": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "md5.js": "^1.3.4", - "ripemd160": "^2.0.1", - "sha.js": "^2.4.0" - } - }, - "node_modules/cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", - "dev": true, - "dependencies": { - "node-fetch": "2.6.7" - } - }, - "node_modules/d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "dev": true, - "dependencies": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/des.js": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", - "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/es5-ext": { - "version": "0.10.60", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.60.tgz", - "integrity": "sha512-jpKNXIt60htYG59/9FGf2PYT3pwMpnEbNKysU+k/4FGwyGtMotOvcZOuW+EmXXYASRqYSXQfGL5cVIthOTgbkg==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", - "dev": true, - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "dev": true, - "dependencies": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, "node_modules/esbuild": { "version": "0.14.54", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.54.tgz", @@ -617,6 +566,15 @@ "node": ">=12" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -626,45 +584,6 @@ "node": ">=0.8.x" } }, - "node_modules/evp_bytestokey": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", - "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", - "dev": true, - "dependencies": { - "md5.js": "^1.3.4", - "safe-buffer": "^5.1.1" - } - }, - "node_modules/ext": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", - "integrity": "sha512-sdBImtzkq2HpkdRLtlLWDa6w4DX22ijZLKx8BMPUuKe1c5lbN6xwQDQCxSfxBQnHZ13ls/FH0MQZx/q/gr6FQg==", - "dev": true, - "dependencies": { - "type": "^2.5.0" - } - }, - "node_modules/ext/node_modules/type": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/type/-/type-2.6.0.tgz", - "integrity": "sha512-eiDBDOmkih5pMbo9OqsqPRGMljLodLcwd5XD5JbtNB0o89xZAwynY9EdCDsJU7LtcVCClu9DvM7/0Ep1hYX3EQ==", - "dev": true - }, - "node_modules/hash-base": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", - "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.4", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -685,936 +604,56 @@ } ] }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true - }, - "node_modules/md5.js": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", - "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", - "dev": true, - "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "node_modules/micro-base": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/micro-base/-/micro-base-0.10.2.tgz", - "integrity": "sha512-lqqJrT7lfJtDmmiQ4zRLZuIJBk96t0RAc5pCrrWpL9zDeH5i/SUL85mku9HqzTI/OCZ8EQ3aicbMW+eK5Nyu5w==", - "deprecated": "Switch to @scure/base for audited version of the lib & updates", - "dev": true - }, - "node_modules/micro-bip32": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/micro-bip32/-/micro-bip32-0.1.0.tgz", - "integrity": "sha512-HxwYJzokbObqPHUqQuzRpCEqZ3EE4uHKrGlLX5ylt0ktD6m9LeS3RkWuQ1HApXEgrGMs3XgykN5Bic2YHE0f6Q==", - "deprecated": "Switch to @scure/bip32 for audited version of the lib & updates", - "dev": true, - "dependencies": { - "@noble/hashes": "^0.5.7", - "@noble/secp256k1": "^1.3.4", - "micro-base": "^0.10.1" - } - }, - "node_modules/micro-bip39": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/micro-bip39/-/micro-bip39-0.1.3.tgz", - "integrity": "sha512-lEaRG/MKxFQvG19lfJfPkLIG0rgT28nWud3otN+VgAbrozGqXn2PLaZuYPsy9guQjIZWBTvoLw/HDJQxmMXjMA==", - "deprecated": "Switch to @scure/bip39 for audited version of the lib & updates", - "dev": true, - "dependencies": { - "@noble/hashes": "^0.5.5", - "micro-base": "^0.10.1" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "dev": true - }, - "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dev": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp-build": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", - "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==", - "dev": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/nostr-tools": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-0.24.1.tgz", - "integrity": "sha512-+aUWblwNTYra8ZsjmfzxStr4XSKAb0gPsehNP3oBiSouLevqD3FWngc++kh8l+zfMYEPPGS6kS0i9iaq/5ZF6A==", - "dev": true, + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.6.0.tgz", + "integrity": "sha512-qjjJQ7YxJUMzgS24eVlxkZ87PKJtU6dlH04OzVuK6w+GSPL+VdUZkMe2lfSpnb7OkCrDIzmbFbtx+Q4LXdU2xw==", "dependencies": { - "@noble/hashes": "^0.5.7", - "@noble/secp256k1": "^1.5.2", - "browserify-cipher": ">=1", - "buffer": ">=5", - "create-hash": "^1.2.0", - "cross-fetch": "^3.1.4", - "micro-bip32": "^0.1.0", - "micro-bip39": "^0.1.3", - "websocket-polyfill": "^0.0.3" + "@noble/hashes": "1.0.0", + "@noble/secp256k1": "^1.7.1", + "@scure/base": "^1.1.1", + "@scure/bip32": "^1.1.5", + "@scure/bip39": "^1.1.1", + "prettier": "^2.8.4" } }, - "node_modules/readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "node_modules/prettier": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz", + "integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==", + "bin": { + "prettier": "bin-prettier.js" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", - "dev": true, - "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "node": ">=10.13.0" }, - "bin": { - "sha.js": "bin.js" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", - "dev": true - }, - "node_modules/tstl": { - "version": "2.5.12", - "resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.12.tgz", - "integrity": "sha512-xAJrE0R+PSxNXnQ7nJ1UPif/gBQYWMnEvIR6c7kKr+7oFrtalo+FunuJHLwpuH4DFClMB1hsaJTAOKkraET9Uw==", - "dev": true - }, - "node_modules/type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==", - "dev": true - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dev": true, - "dependencies": { - "is-typedarray": "^1.0.0" + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "dev": true, - "hasInstallScript": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, "engines": { - "node": ">=6.14.2" + "node": ">= 0.6.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", - "dev": true - }, - "node_modules/websocket": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", - "integrity": "sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==", + "node_modules/readable-stream": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.3.0.tgz", + "integrity": "sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ==", "dev": true, "dependencies": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.50", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10" }, "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/websocket-polyfill": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/websocket-polyfill/-/websocket-polyfill-0.0.3.tgz", - "integrity": "sha512-pF3kR8Uaoau78MpUmFfzbIRxXj9PeQrCuPepGE6JIsfsJ/o/iXr07Q2iQNzKSSblQJ0FiGWlS64N4pVSm+O3Dg==", - "dev": true, - "dependencies": { - "tstl": "^2.0.7", - "websocket": "^1.0.28" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "dev": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", - "dev": true, - "engines": { - "node": ">=0.10.32" - } - } - }, - "dependencies": { - "@esbuild-plugins/node-globals-polyfill": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.1.1.tgz", - "integrity": "sha512-MR0oAA+mlnJWrt1RQVQ+4VYuRJW/P2YmRTv1AsplObyvuBMnPHiizUF95HHYiSsMGLhyGtWufaq2XQg6+iurBg==", - "dev": true, - "requires": {} - }, - "@esbuild/linux-loong64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz", - "integrity": "sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==", - "dev": true, - "optional": true - }, - "@noble/hashes": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-0.5.9.tgz", - "integrity": "sha512-7lN1Qh6d8DUGmfN36XRsbN/WcGIPNtTGhkw26vWId/DlCIGsYJJootTtPGghTLcn/AaXPx2Q0b3cacrwXa7OVw==", - "dev": true - }, - "@noble/secp256k1": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.0.tgz", - "integrity": "sha512-kbacwGSsH/CTout0ZnZWxnW1B+jH/7r/WAAKLBtrRJ/+CUH7lgmQzl3GTrQua3SGKWNSDsS6lmjnDpIJ5Dxyaw==", - "dev": true - }, - "base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true - }, - "browserify-aes": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", - "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", - "dev": true, - "requires": { - "buffer-xor": "^1.0.3", - "cipher-base": "^1.0.0", - "create-hash": "^1.1.0", - "evp_bytestokey": "^1.0.3", - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "browserify-cipher": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", - "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", - "dev": true, - "requires": { - "browserify-aes": "^1.0.4", - "browserify-des": "^1.0.0", - "evp_bytestokey": "^1.0.0" - } - }, - "browserify-des": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", - "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", - "dev": true, - "requires": { - "cipher-base": "^1.0.1", - "des.js": "^1.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "buffer-xor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", - "dev": true - }, - "bufferutil": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz", - "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==", - "dev": true, - "requires": { - "node-gyp-build": "^4.3.0" - } - }, - "cipher-base": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", - "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "create-hash": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", - "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", - "dev": true, - "requires": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "md5.js": "^1.3.4", - "ripemd160": "^2.0.1", - "sha.js": "^2.4.0" - } - }, - "cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", - "dev": true, - "requires": { - "node-fetch": "2.6.7" - } - }, - "d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "dev": true, - "requires": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "des.js": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", - "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, - "es5-ext": { - "version": "0.10.60", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.60.tgz", - "integrity": "sha512-jpKNXIt60htYG59/9FGf2PYT3pwMpnEbNKysU+k/4FGwyGtMotOvcZOuW+EmXXYASRqYSXQfGL5cVIthOTgbkg==", - "dev": true, - "requires": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "next-tick": "^1.1.0" - } - }, - "es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", - "dev": true, - "requires": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "dev": true, - "requires": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, - "esbuild": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.54.tgz", - "integrity": "sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==", - "dev": true, - "requires": { - "@esbuild/linux-loong64": "0.14.54", - "esbuild-android-64": "0.14.54", - "esbuild-android-arm64": "0.14.54", - "esbuild-darwin-64": "0.14.54", - "esbuild-darwin-arm64": "0.14.54", - "esbuild-freebsd-64": "0.14.54", - "esbuild-freebsd-arm64": "0.14.54", - "esbuild-linux-32": "0.14.54", - "esbuild-linux-64": "0.14.54", - "esbuild-linux-arm": "0.14.54", - "esbuild-linux-arm64": "0.14.54", - "esbuild-linux-mips64le": "0.14.54", - "esbuild-linux-ppc64le": "0.14.54", - "esbuild-linux-riscv64": "0.14.54", - "esbuild-linux-s390x": "0.14.54", - "esbuild-netbsd-64": "0.14.54", - "esbuild-openbsd-64": "0.14.54", - "esbuild-sunos-64": "0.14.54", - "esbuild-windows-32": "0.14.54", - "esbuild-windows-64": "0.14.54", - "esbuild-windows-arm64": "0.14.54" - } - }, - "esbuild-android-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz", - "integrity": "sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==", - "dev": true, - "optional": true - }, - "esbuild-android-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz", - "integrity": "sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==", - "dev": true, - "optional": true - }, - "esbuild-darwin-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz", - "integrity": "sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==", - "dev": true, - "optional": true - }, - "esbuild-darwin-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz", - "integrity": "sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz", - "integrity": "sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz", - "integrity": "sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==", - "dev": true, - "optional": true - }, - "esbuild-linux-32": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz", - "integrity": "sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==", - "dev": true, - "optional": true - }, - "esbuild-linux-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz", - "integrity": "sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz", - "integrity": "sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz", - "integrity": "sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==", - "dev": true, - "optional": true - }, - "esbuild-linux-mips64le": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz", - "integrity": "sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==", - "dev": true, - "optional": true - }, - "esbuild-linux-ppc64le": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz", - "integrity": "sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==", - "dev": true, - "optional": true - }, - "esbuild-linux-riscv64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz", - "integrity": "sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==", - "dev": true, - "optional": true - }, - "esbuild-linux-s390x": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz", - "integrity": "sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==", - "dev": true, - "optional": true - }, - "esbuild-netbsd-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz", - "integrity": "sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==", - "dev": true, - "optional": true - }, - "esbuild-openbsd-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz", - "integrity": "sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==", - "dev": true, - "optional": true - }, - "esbuild-plugin-alias": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/esbuild-plugin-alias/-/esbuild-plugin-alias-0.2.1.tgz", - "integrity": "sha512-jyfL/pwPqaFXyKnj8lP8iLk6Z0m099uXR45aSN8Av1XD4vhvQutxxPzgA2bTcAwQpa1zCXDcWOlhFgyP3GKqhQ==", - "dev": true - }, - "esbuild-sunos-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz", - "integrity": "sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==", - "dev": true, - "optional": true - }, - "esbuild-windows-32": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz", - "integrity": "sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==", - "dev": true, - "optional": true - }, - "esbuild-windows-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz", - "integrity": "sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==", - "dev": true, - "optional": true - }, - "esbuild-windows-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz", - "integrity": "sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==", - "dev": true, - "optional": true - }, - "events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true - }, - "evp_bytestokey": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", - "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", - "dev": true, - "requires": { - "md5.js": "^1.3.4", - "safe-buffer": "^5.1.1" - } - }, - "ext": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", - "integrity": "sha512-sdBImtzkq2HpkdRLtlLWDa6w4DX22ijZLKx8BMPUuKe1c5lbN6xwQDQCxSfxBQnHZ13ls/FH0MQZx/q/gr6FQg==", - "dev": true, - "requires": { - "type": "^2.5.0" - }, - "dependencies": { - "type": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/type/-/type-2.6.0.tgz", - "integrity": "sha512-eiDBDOmkih5pMbo9OqsqPRGMljLodLcwd5XD5JbtNB0o89xZAwynY9EdCDsJU7LtcVCClu9DvM7/0Ep1hYX3EQ==", - "dev": true - } - } - }, - "hash-base": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", - "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", - "dev": true, - "requires": { - "inherits": "^2.0.4", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" - } - }, - "ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true - }, - "md5.js": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", - "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", - "dev": true, - "requires": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "micro-base": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/micro-base/-/micro-base-0.10.2.tgz", - "integrity": "sha512-lqqJrT7lfJtDmmiQ4zRLZuIJBk96t0RAc5pCrrWpL9zDeH5i/SUL85mku9HqzTI/OCZ8EQ3aicbMW+eK5Nyu5w==", - "dev": true - }, - "micro-bip32": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/micro-bip32/-/micro-bip32-0.1.0.tgz", - "integrity": "sha512-HxwYJzokbObqPHUqQuzRpCEqZ3EE4uHKrGlLX5ylt0ktD6m9LeS3RkWuQ1HApXEgrGMs3XgykN5Bic2YHE0f6Q==", - "dev": true, - "requires": { - "@noble/hashes": "^0.5.7", - "@noble/secp256k1": "^1.3.4", - "micro-base": "^0.10.1" - } - }, - "micro-bip39": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/micro-bip39/-/micro-bip39-0.1.3.tgz", - "integrity": "sha512-lEaRG/MKxFQvG19lfJfPkLIG0rgT28nWud3otN+VgAbrozGqXn2PLaZuYPsy9guQjIZWBTvoLw/HDJQxmMXjMA==", - "dev": true, - "requires": { - "@noble/hashes": "^0.5.5", - "micro-base": "^0.10.1" - } - }, - "minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - }, - "next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "dev": true - }, - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dev": true, - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "node-gyp-build": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", - "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==", - "dev": true - }, - "nostr-tools": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-0.24.1.tgz", - "integrity": "sha512-+aUWblwNTYra8ZsjmfzxStr4XSKAb0gPsehNP3oBiSouLevqD3FWngc++kh8l+zfMYEPPGS6kS0i9iaq/5ZF6A==", - "dev": true, - "requires": { - "@noble/hashes": "^0.5.7", - "@noble/secp256k1": "^1.5.2", - "browserify-cipher": ">=1", - "buffer": ">=5", - "create-hash": "^1.2.0", - "cross-fetch": "^3.1.4", - "micro-bip32": "^0.1.0", - "micro-bip39": "^0.1.3", - "websocket-polyfill": "^0.0.3" - } - }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", - "dev": true, - "requires": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", - "dev": true - }, - "tstl": { - "version": "2.5.12", - "resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.12.tgz", - "integrity": "sha512-xAJrE0R+PSxNXnQ7nJ1UPif/gBQYWMnEvIR6c7kKr+7oFrtalo+FunuJHLwpuH4DFClMB1hsaJTAOKkraET9Uw==", - "dev": true - }, - "type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==", - "dev": true - }, - "typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dev": true, - "requires": { - "is-typedarray": "^1.0.0" - } - }, - "utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "dev": true, - "requires": { - "node-gyp-build": "^4.3.0" - } - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true - }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", - "dev": true - }, - "websocket": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", - "integrity": "sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==", - "dev": true, - "requires": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.50", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - } - }, - "websocket-polyfill": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/websocket-polyfill/-/websocket-polyfill-0.0.3.tgz", - "integrity": "sha512-pF3kR8Uaoau78MpUmFfzbIRxXj9PeQrCuPepGE6JIsfsJ/o/iXr07Q2iQNzKSSblQJ0FiGWlS64N4pVSm+O3Dg==", - "dev": true, - "requires": { - "tstl": "^2.0.7", - "websocket": "^1.0.28" - } - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "dev": true, - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", - "dev": true } } } diff --git a/package.json b/package.json index bbe1fd3..ba92ecb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "esbuild": "^0.14.54", "esbuild-plugin-alias": "^0.2.1", "events": "^3.3.0", - "nostr-tools": "0.24.1" + "readable-stream": "4.3.0" + }, + "dependencies": { + "nostr-tools": "1.6.0" }, "scripts": { "build": "node tools/build.js", diff --git a/src/main.js b/src/main.js index 6b04424..87c259d 100644 --- a/src/main.js +++ b/src/main.js @@ -1,22 +1,11 @@ -import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; +import {generatePrivateKey, getEventHash, getPublicKey, signEvent} from 'nostr-tools'; +import {publish, sub, unsubAll} from './relays'; import {bounce} from './utils.js'; import {zeroLeadingBitsCount} from './cryptoutils.js'; import {elem, parseTextContent} from './domutil.js'; import {dateTime, formatTime} from './timeutil.js'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ -const pool = relayPool(); -// pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); -// pool.addRelay('wss://relay.damus.io', {read: true, write: true}); -// pool.addRelay('wss://relay.snort.social', {read: true, write: true}); - -pool.addRelay('wss://relay.nostr.ch', {read: true, write: true}); -pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); -pool.addRelay('wss://eden.nostr.land', {read: true, write: true}); -pool.addRelay('wss://nostr.einundzwanzig.space', {read: true, write: true}); -pool.addRelay('wss://relay.nostrich.de', {read: true, write: true}); -pool.addRelay('wss://nostr.cercatrova.me', {read: true, write: true}); - function onEvent(evt, relay) { switch (evt.kind) { case 0: @@ -46,14 +35,8 @@ let pubkey = localStorage.getItem('pub_key') || (() => { return pubkey; })(); -const subList = []; -const unSubAll = () => { - subList.forEach(sub => sub.unsub()); - subList.length = 0; -}; - function sub24hFeed() { - subList.push(pool.sub({ + sub({ cb: onEvent, filter: { kinds: [0, 1, 2, 7], @@ -61,7 +44,7 @@ function sub24hFeed() { since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)), limit: 50, } - })); + }); } function subNoteAndProfile(id) { @@ -71,18 +54,18 @@ function subNoteAndProfile(id) { } function subTextNote(eventId) { - subList.push(pool.sub({ + sub({ cb: onEvent, filter: { ids: [eventId], kinds: [1], limit: 1, } - })); + }); } function subProfile(pubkey) { - subList.push(pool.sub({ + sub({ cb: (evt, relay) => { console.log('found profile, unsub subTextNote somehow') // renderProfile(evt, relay); @@ -93,16 +76,16 @@ function subProfile(pubkey) { kinds: [0], limit: 1, } - })); + }); // get notes for profile - subList.push(pool.sub({ + sub({ cb: onEvent, filter: { authors: [pubkey], kinds: [1], limit: 50, } - })); + }); } const containers = [ @@ -115,39 +98,6 @@ const containers = [ ]; let activeContainerIndex = null; -// const profileContainer = document.querySelector('#profile'); -// const profileAbout = profileContainer.querySelector('.profile-about'); -// const profileName = profileContainer.querySelector('.profile-name'); -// const profilePubkey = profileContainer.querySelector('.profile-pubkey'); -// const profilePubkeyLabel = profileContainer.querySelector('.profile-pubkey-label'); -// const profileImage = profileContainer.querySelector('.profile-image'); -// const textNoteContainer = document.querySelector('#textnote'); - -// function clearProfile() { -// profileAbout.textContent = ''; -// profileName.textContent = ''; -// profilePubkey.textContent = ''; -// profilePubkeyLabel.hidden = true; -// profileImage.removeAttribute('src'); -// profileImage.hidden = true; -// } -// function renderProfile(evt, relay) { -// profileContainer.dataset.pubkey = evt.pubkey; -// profilePubkey.textContent = evt.pubkey; -// profilePubkeyLabel.hidden = false; -// const content = parseContent(evt.content); -// if (content) { -// profileAbout.textContent = content.about; -// profileName.textContent = content.name; -// const noxyImg = validatePow(evt) && getNoxyUrl('data', content.picture, evt.id, relay); -// if (noxyImg) { -// profileImage.setAttribute('src', noxyImg); -// profileImage.hidden = false; -// } -// } -// } - - const textNoteList = []; // could use indexDB const eventRelayMap = {}; // eventId: [relay1, relay2] @@ -356,6 +306,8 @@ function linkPreview(href, id, relay) { }); } +const writeInput = document.querySelector('textarea[name="message"]'); + function createTextNote(evt, relay) { const {host, img, name, time, userName} = getMetadata(evt, relay); const replies = replyList.filter(({replyTo}) => replyTo === evt.id); @@ -625,7 +577,6 @@ function getReplyTo(evt) { } const writeForm = document.querySelector('#writeForm'); -const writeInput = document.querySelector('textarea[name="message"]'); const elemShrink = () => { const height = writeInput.style.height || writeInput.getBoundingClientRect().height; @@ -718,17 +669,16 @@ async function upvote(eventId, eventPubkey) { return; } const privatekey = localStorage.getItem('private_key'); - const sig = await signEvent(newReaction, privatekey).catch(console.error); + const sig = signEvent(newReaction, privatekey); + // TODO: validateEvent 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}`); + publish({...newReaction, sig}, (relay, error) => { + if (error) { + return console.error(error, relay); } - }).catch(console.error); + console.info(`event published by ${relay}`); + }); reactionBtn.disabled = false; } } @@ -736,7 +686,7 @@ async function upvote(eventId, eventPubkey) { // send const sendStatus = document.querySelector('#sendstatus'); const onSendError = err => sendStatus.textContent = err.message; -const publish = document.querySelector('#publish'); +const publishBtn = document.querySelector('#publish'); writeForm.addEventListener('submit', async (e) => { e.preventDefault(); // const pubkey = localStorage.getItem('pub_key'); @@ -753,7 +703,7 @@ writeForm.addEventListener('submit', async (e) => { sendStatus.textContent = ''; writeInput.value = ''; writeInput.style.removeProperty('height'); - publish.disabled = true; + publishBtn.disabled = true; if (replyTo) { localStorage.removeItem('reply_to'); publishView.append(writeForm); @@ -772,23 +722,22 @@ writeForm.addEventListener('submit', async (e) => { close(); return; } - const sig = await signEvent(newEvent, privatekey).catch(onSendError); + const sig = signEvent(newEvent, privatekey); + // TODO validateEvent 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); + publish({...newEvent, sig}, (relay, error) => { + if (error) { + return console.log(error, relay); } + console.info(`publish request sent to ${relay}`); + close(); }); } }); writeInput.addEventListener('input', () => { - publish.disabled = !writeInput.value.trimRight(); + publishBtn.disabled = !writeInput.value.trimRight(); updateElemHeight(writeInput); }); writeInput.addEventListener('blur', () => sendStatus.textContent = ''); @@ -918,7 +867,7 @@ switch(location.pathname) { window.addEventListener('popstate', (event) => { // console.log(`popstate: ${location.pathname}, state: ${JSON.stringify(event.state)}`); - unSubAll(); + unsubAll(); if (event.state?.pubkey) { subProfile(event.state.pubkey); view(`/${event.state.pubkey}`); @@ -961,18 +910,18 @@ document.body.addEventListener('click', (e) => { switch(href) { case '/': navigate('/'); - unSubAll(); + unsubAll(); sub24hFeed(); break; default: switch(a.dataset.nav) { case '/[profile]': - unSubAll(); + unsubAll(); subProfile(pubkey); navigate({pubkey}); break; case '/[note]': - unSubAll(); + unsubAll(); subTextNote(id) navigate({note: id}); break; @@ -1144,18 +1093,18 @@ profileForm.addEventListener('submit', async (e) => { return; } const privatekey = localStorage.getItem('private_key'); - const sig = await signEvent(newProfile, privatekey).catch(console.error); + const sig = signEvent(newProfile, privatekey); + // TODO: validateEvent 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; + publish({...newProfile, sig}, (relay, error) => { + if (error) { + return console.error(error, relay); } - }).catch(console.error); + console.info(`publish request sent to ${relay}`); + profileStatus.textContent = 'profile metadata successfully published'; + profileStatus.hidden = false; + profileSubmit.disabled = true; + }); } }); @@ -1223,7 +1172,10 @@ function validatePow(evt) { function powEvent(evt, options) { const {difficulty, statusElem, timeout} = options; if (difficulty === 0) { - return Promise.resolve(evt); + return Promise.resolve({ + id: getEventHash(evt), + ...evt, + }); } const cancelBtn = elem('button', {className: 'btn-inline'}, [elem('small', {}, 'cancel')]); statusElem.replaceChildren('working…', cancelBtn); diff --git a/src/relays.ts b/src/relays.ts new file mode 100644 index 0000000..66b7250 --- /dev/null +++ b/src/relays.ts @@ -0,0 +1,99 @@ +import {Event, Filter, relayInit, Relay, Sub} from 'nostr-tools'; + +type SubCallback = ( + event: Readonly, + relay: Readonly, +) => void; + +type Subscribe = { + cb: SubCallback; + filter: Filter; +}; + +const relayList: Array = []; +const subList: Array = []; +const currentSubList: Array = []; + +export const addRelay = async (url: string) => { + const relay = relayInit(url); + relay.on('connect', () => { + console.info(`connected to ${relay.url}`); + }); + relay.on('error', () => { + console.warn(`failed to connect to ${relay.url}`); + }); + try { + await relay.connect(); + currentSubList.forEach(({cb, filter}) => subscribe(cb, filter, relay)); + relayList.push(relay); + } catch { + console.warn(`could not connect to ${url}`); + } +}; + +const unsubscribe = (sub: Sub) => { + sub.unsub(); + subList.splice(subList.indexOf(sub), 1); +}; + +const subscribe = ( + cb: SubCallback, + filter: Filter, + relay: Relay, +) => { + const sub = relay.sub([filter]); + subList.push(sub); + sub.on('event', (event: Event) => { + cb(event, relay.url); + }); + sub.on('eose', () => { + // console.log('eose', relay.url); + // unsubscribe(sub); + }); +}; + +const subscribeAll = ( + cb: SubCallback, + filter: Filter, +) => { + relayList.forEach(relay => subscribe(cb, filter, relay)); +}; + +export const sub = (obj: Subscribe) => { + currentSubList.push(obj); + subscribeAll(obj.cb, obj.filter); +}; + +export const unsubAll = () => { + subList.forEach(unsubscribe); + currentSubList.length = 0; +}; + +type PublishCallback = ( + relay: string, + errorMessage?: string, +) => void; + +export const publish = ( + event: Event, + cb: PublishCallback, +) => { + relayList.forEach(relay => { + const pub = relay.publish(event); + pub.on('ok', () => { + console.info(`${relay.url} has accepted our event`); + cb(relay.url); + }); + pub.on('failed', (reason: any) => { + console.error(`failed to publish to ${relay.url}: ${reason}`); + cb(relay.url, reason); + }); + }); +}; + +addRelay('wss://relay.snort.social'); // good one +addRelay('wss://nostr.bitcoiner.social'); +addRelay('wss://nostr.mom'); +addRelay('wss://relay.nostr.bg'); +addRelay('wss://nos.lol'); +addRelay('wss://relay.nostr.ch'); diff --git a/src/worker.js b/src/worker.js index 726adb3..2272cd1 100644 --- a/src/worker.js +++ b/src/worker.js @@ -26,7 +26,7 @@ function mine(event, difficulty, timeout = 5) { const id = getEventHash(event); if (zeroLeadingBitsCount(id) === difficulty) { console.timeEnd('pow'); - return event; + return {id, ...event}; } } } -- 2.46.2 From b0e190fd2255076ea9faea36a815f5d26a034f98 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Fri, 24 Feb 2023 11:53:19 +0100 Subject: [PATCH 03/34] nip19: use bech32 npub and note in uris convert pubkey to npub and event id to note and store for later. use npub and note for uri's so that we dont need to guess what the view of a specific uri has to render. this is only the first step that changes uri's, next step is for the view to react to npub or note and render the correct view accordingly. --- src/main.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main.js b/src/main.js index 87c259d..ad6ca86 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,4 @@ -import {generatePrivateKey, getEventHash, getPublicKey, signEvent} from 'nostr-tools'; +import {generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent} from 'nostr-tools'; import {publish, sub, unsubAll} from './relays'; import {bounce} from './utils.js'; import {zeroLeadingBitsCount} from './cryptoutils.js'; @@ -137,10 +137,17 @@ function handleTextNote(evt, relay) { eventRelayMap[evt.id] = [relay, ...(eventRelayMap[evt.id])]; } else { eventRelayMap[evt.id] = [relay]; + const evtWithNip19 = { + nip19: { + note: nip19.noteEncode(evt.id), + npub: nip19.npubEncode(evt.pubkey), + }, + ...evt + }; if (evt.tags.some(hasEventTag)) { - handleReply(evt, relay); + handleReply(evtWithNip19, relay); } else { - textNoteList.push(evt); + textNoteList.push(evtWithNip19); } } if (!getViewElem(evt.id)) { @@ -324,9 +331,9 @@ function createTextNote(evt, relay) { ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} ${evt.content}` }, [ - elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.pubkey}`, data: {nav: '/[profile]'}}, name || userName), + elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`, data: {nav: '/[profile]'}}, name || userName), ' ', - elem('a', {href: `/${evt.id}`, data: {nav: '/[note]'}}, formatTime(time)), + elem('a', {href: `/${evt.nip19.note}`, data: {nav: '/[note]'}}, formatTime(time)), ]), elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ ...content, -- 2.46.2 From 309367852a226b7a50c0e6e470d0be56ab664523 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 26 Feb 2023 09:29:07 +0100 Subject: [PATCH 04/34] routes: use nip19 as routes so we need no guessing logic and know what to subscribe too. in a later step it add more to the view i.e. show profile meta data for npub. --- src/main.js | 108 ++++++++++++++-------------------------------------- 1 file changed, 28 insertions(+), 80 deletions(-) diff --git a/src/main.js b/src/main.js index ad6ca86..7e752cc 100644 --- a/src/main.js +++ b/src/main.js @@ -98,6 +98,7 @@ const containers = [ ]; let activeContainerIndex = null; + const textNoteList = []; // could use indexDB const eventRelayMap = {}; // eventId: [relay1, relay2] @@ -331,9 +332,9 @@ function createTextNote(evt, relay) { ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} ${evt.content}` }, [ - elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`, data: {nav: '/[profile]'}}, name || userName), + elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`, data: {nav: true}}, name || userName), ' ', - elem('a', {href: `/${evt.nip19.note}`, data: {nav: '/[note]'}}, formatTime(time)), + elem('a', {href: `/${evt.nip19.note}`, data: {nav: true}}, formatTime(time)), ]), elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ ...content, @@ -833,68 +834,36 @@ function view(route) { }); } -function navigate(route) { - if (typeof route === 'string') { - view(route); - history.pushState({}, '', route); - return; - } - if (route.pubkey) { - view(`/${route.pubkey}`); - history.pushState(route, '', `/${route.pubkey}`); - return; - } - if (route.note) { - view(`/${route.note}`); - history.pushState(route, '', `/${route.note}`); - return; - } - if (route.pubOrNote) { - view(`/${route.pubOrNote}`); - history.pushState(route, '', `/${route.pubOrNote}`); - return; +// subscribe and change view +function route(path) { + unsubAll(); + if (path === '/') { + sub24hFeed(); + view('/'); + } else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) { + const {type, data} = nip19.decode(path.slice(1)); + switch(type) { + case 'note': + subNote(data); + view(path); + break; + case 'npub': + subProfile(data); + view(path); + break; + default: + console.warn(`type ${type} not yet supported`); + } } - console.warn('unhandeleded', route); } // onload -switch(location.pathname) { - case '/': - sub24hFeed(); - navigate('/'); - break; - default: - const pubOrNote = location.pathname.slice(1); - if (pubOrNote.length === 64 && pubOrNote.match(/^[0-9a-f]+$/)) { - navigate({pubOrNote}); - subNoteAndProfile(pubOrNote); - } - break; -} +route(location.pathname); +history.pushState({}, '', location.pathname); window.addEventListener('popstate', (event) => { // console.log(`popstate: ${location.pathname}, state: ${JSON.stringify(event.state)}`); - unsubAll(); - if (event.state?.pubkey) { - subProfile(event.state.pubkey); - view(`/${event.state.pubkey}`); - return; - } - if (event.state?.pubOrNote) { - subNoteAndProfile(event.state.pubOrNote); - view(`/${event.state.pubOrNote}`); // assuming note - return; - } - if (event.state?.note) { - subTextNote(event.state.note); - view(`/${event.state.note}`); // assuming note - return; - } - if (location.pathname === '/') { - sub24hFeed(); - view('/'); - return; - } + route(location.pathname); }); const settingsView = document.querySelector('#settings'); @@ -914,29 +883,8 @@ document.body.addEventListener('click', (e) => { publishView.hidden = true; } const href = a.getAttribute('href'); - switch(href) { - case '/': - navigate('/'); - unsubAll(); - sub24hFeed(); - break; - default: - switch(a.dataset.nav) { - case '/[profile]': - unsubAll(); - subProfile(pubkey); - navigate({pubkey}); - break; - case '/[note]': - unsubAll(); - subTextNote(id) - navigate({note: id}); - break; - default: - console.warn('what route is that', href); - } - break; - } + route(href); + history.pushState({}, null, href); e.preventDefault(); } return; -- 2.46.2 From 35b8baef92e2bee3e3b488af0a43fc8c7d4cf386 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Fri, 3 Mar 2023 18:58:29 +0100 Subject: [PATCH 05/34] refactor: type subscriptions.ts typed subscribe functions in subscriptions.ts --- src/main.js | 63 ++++------------------------------------ src/subscriptions.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 58 deletions(-) create mode 100644 src/subscriptions.ts diff --git a/src/main.js b/src/main.js index 7e752cc..0434cb0 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,6 @@ import {generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent} from 'nostr-tools'; -import {publish, sub, unsubAll} from './relays'; +import {sub24hFeed, subNote, subProfile} from './subscriptions' +import {publish} from './relays'; import {bounce} from './utils.js'; import {zeroLeadingBitsCount} from './cryptoutils.js'; import {elem, parseTextContent} from './domutil.js'; @@ -35,59 +36,6 @@ let pubkey = localStorage.getItem('pub_key') || (() => { return pubkey; })(); -function sub24hFeed() { - sub({ - cb: onEvent, - filter: { - kinds: [0, 1, 2, 7], - // until: Math.floor(Date.now() * 0.001), - since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)), - limit: 50, - } - }); -} - -function subNoteAndProfile(id) { - // view(`/${id}`); // assume text note - subTextNote(id); - subProfile(id); -} - -function subTextNote(eventId) { - sub({ - cb: onEvent, - filter: { - ids: [eventId], - kinds: [1], - limit: 1, - } - }); -} - -function subProfile(pubkey) { - sub({ - cb: (evt, relay) => { - console.log('found profile, unsub subTextNote somehow') - // renderProfile(evt, relay); - // view('/[profile]'); - }, - filter: { - authors: [pubkey], - kinds: [0], - limit: 1, - } - }); - // get notes for profile - sub({ - cb: onEvent, - filter: { - authors: [pubkey], - kinds: [1], - limit: 50, - } - }); -} - const containers = [ // { // id: '/00527c2b28ea78446c148cb40cc6e442ea3d0945ff5a8b71076483398525b54d', @@ -836,19 +784,18 @@ function view(route) { // subscribe and change view function route(path) { - unsubAll(); if (path === '/') { - sub24hFeed(); + sub24hFeed(onEvent); view('/'); } else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) { const {type, data} = nip19.decode(path.slice(1)); switch(type) { case 'note': - subNote(data); + subNote(data, onEvent); view(path); break; case 'npub': - subProfile(data); + subProfile(data, onEvent); view(path); break; default: diff --git a/src/subscriptions.ts b/src/subscriptions.ts new file mode 100644 index 0000000..19e63c1 --- /dev/null +++ b/src/subscriptions.ts @@ -0,0 +1,69 @@ +import {Event} from 'nostr-tools'; +import {sub, unsubAll} from './relays'; + +type SubCallback = ( + event: Event, + relay: string, +) => void; + +/** subscribe to global feed */ +export const sub24hFeed = (onEvent: SubCallback) => { + unsubAll(); + sub({ + cb: onEvent, + filter: { + kinds: [0, 1, 2, 7], + // until: Math.floor(Date.now() * 0.001), + since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)), + limit: 50, + } + }); +}; + +/** subscribe to a note id (nip-19) */ +export const subNote = ( + eventId: string, + onEvent: SubCallback, +) => { + unsubAll(); + sub({ + cb: onEvent, + filter: { + ids: [eventId], + kinds: [1], + limit: 1, + } + }); + sub({ + cb: onEvent, + filter: { + '#e': [eventId], + kinds: [1, 7], + } + }); +}; + +/** subscribe to npub key (nip-19) */ +export const subProfile = ( + pubkey: string, + onEvent: SubCallback, +) => { + unsubAll(); + sub({ + cb: onEvent, + filter: { + authors: [pubkey], + kinds: [0], + limit: 1, + } + }); + // get notes for profile + sub({ + cb: onEvent, + filter: { + authors: [pubkey], + kinds: [1], + limit: 50, + } + }); +}; -- 2.46.2 From fa97027321914e64a8f2a3efe647725afe03a8b9 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 4 Mar 2023 12:22:27 +0100 Subject: [PATCH 06/34] refactor: type view.ts, dom.ts and time.ts --- src/domutil.js | 92 -------------------- src/main.js | 78 +---------------- src/utils.js | 22 ----- src/utils/dom.ts | 129 +++++++++++++++++++++++++++++ src/utils/index.ts | 2 + src/{timeutil.js => utils/time.ts} | 47 +++++++++-- src/view.ts | 76 +++++++++++++++++ tsconfig.json | 6 ++ 8 files changed, 255 insertions(+), 197 deletions(-) delete mode 100644 src/domutil.js delete mode 100644 src/utils.js create mode 100644 src/utils/dom.ts create mode 100644 src/utils/index.ts rename src/{timeutil.js => utils/time.ts} (66%) create mode 100644 src/view.ts create mode 100644 tsconfig.json diff --git a/src/domutil.js b/src/domutil.js deleted file mode 100644 index bbdb1ee..0000000 --- a/src/domutil.js +++ /dev/null @@ -1,92 +0,0 @@ -/** - * example usage: - * - * const props = {className: 'btn', onclick: async (e) => alert('hi')}; - * const btn = elem('button', props, ['download']); - * document.body.append(btn); - * - * @param {string} name - * @param {HTMLElement.prototype} props - * @param {Array} children - * @return HTMLElement - */ -export function elem(name = 'div', {data, ...props} = {}, children = []) { - const el = document.createElement(name); - Object.assign(el, props); - if (['number', 'string'].includes(typeof children)) { - el.append(children); - } else { - el.append(...children); - } - if (data) { - Object.entries(data).forEach(([key, value]) => el.dataset[key] = value); - } - return el; -} - -function isValidURL(url) { - if (!['http:', 'https:'].includes(url.protocol)) { - return false; - } - if (!['', '443', '80'].includes(url.port)) { - return false; - } - if (url.hostname === 'localhost') { - return false; - } - const lastDot = url.hostname.lastIndexOf('.'); - if (lastDot < 1) { - return false; - } - if (url.hostname.slice(lastDot) === '.local') { - return false; - } - if (url.hostname.slice(lastDot + 1).match(/^[\d]+$/)) { // there should be no tld with numbers, possible ipv4 - return false; - } - if (url.hostname.includes(':')) { // possibly an ipv6 addr; certainly an invalid hostname - return false; - } - return true; -} - -export function parseTextContent(string) { - let firstLink; - return [string - .trimRight() - .replaceAll(/\n{3,}/g, '\n\n') - .split('\n') - .map(line => { - const words = line.split(/\s/); - return words.map(word => { - if (word.match(/^ln(tbs?|bcr?t?)[a-z0-9]+$/g)) { - return elem('a', { - href: `lightning:${word}` - }, `lightning:${word.slice(0, 24)}…`); - } - if (!word.match(/^(https?:\/\/|www\.)\S*/)) { - return word; - } - try { - if (!word.startsWith('http')) { - word = 'https://' + word; - } - const url = new URL(word); - if (!isValidURL(url)) { - return word; - } - firstLink = firstLink || url.href; - return elem('a', { - href: url.href, - target: '_blank', - rel: 'noopener noreferrer' - }, url.href.slice(url.protocol.length + 2)); - } catch (err) { - return word; - } - }) - .reduce((acc, word) => [...acc, word, ' '], []); - }) - .reduce((acc, words) => [...acc, ...words, elem('br')], []), - {firstLink}]; -} diff --git a/src/main.js b/src/main.js index 0434cb0..31e9a3c 100644 --- a/src/main.js +++ b/src/main.js @@ -1,10 +1,9 @@ import {generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent} from 'nostr-tools'; import {sub24hFeed, subNote, subProfile} from './subscriptions' import {publish} from './relays'; -import {bounce} from './utils.js'; +import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; import {zeroLeadingBitsCount} from './cryptoutils.js'; -import {elem, parseTextContent} from './domutil.js'; -import {dateTime, formatTime} from './timeutil.js'; +import {bounce, dateTime, elem, formatTime, parseTextContent} from './utils'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ function onEvent(evt, relay) { @@ -36,16 +35,6 @@ let pubkey = localStorage.getItem('pub_key') || (() => { return pubkey; })(); -const containers = [ - // { - // id: '/00527c2b28ea78446c148cb40cc6e442ea3d0945ff5a8b71076483398525b54d', - // view: Node, - // content: Node, - // dom: {} - // } -]; -let activeContainerIndex = null; - const textNoteList = []; // could use indexDB const eventRelayMap = {}; // eventId: [relay1, relay2] @@ -180,9 +169,7 @@ const sortByCreatedAt = (evt1, evt2) => { }; function rerenderFeed() { - const domMap = getViewDom(); // TODO: this is only the current view, do this for all views - Object.keys(domMap).forEach(key => delete domMap[key]); - getViewContent().replaceChildren([]); + clearView(); renderFeed(); } @@ -719,68 +706,11 @@ function updateElemHeight(el) { -function getViewContent() { - return containers[activeContainerIndex]?.content; -} - -function getViewDom() { - return containers[activeContainerIndex]?.dom; -} -function getViewElem(key) { - return containers[activeContainerIndex]?.dom[key]; -} - -function setViewElem(key, node) { - const container = containers[activeContainerIndex]; - if (container) { - container.dom[key] = node; - } - return node; -} - -const mainContainer = document.querySelector('main'); - -const getContainer = (containers, route) => { - let container = containers.find(c => c.route === route); - if (container) { - return container; - } - const content = elem('div', {className: 'content'}); - const view = elem('section', {className: 'view'}, [content]); - mainContainer.append(view); - container = {route, view, content, dom: {}}; - containers.push(container); - return container; -}; document.body.onload = () => console.log('------------ pageload ------------') -function view(route) { - const active = containers[activeContainerIndex]; - active?.view.classList.remove('view-active'); - const nextContainer = getContainer(containers, route); - const nextContainerIndex = containers.indexOf(nextContainer); - if (nextContainerIndex === activeContainerIndex) { - return; - } - if (active) { - nextContainer.view.classList.add('view-next'); - } - requestAnimationFrame(() => { - requestAnimationFrame(() => { - nextContainer.view.classList.remove('view-next', 'view-prev'); - nextContainer.view.classList.add('view-active'); - }); - // // console.log(activeContainerIndex, nextContainerIndex); - getViewContent()?.querySelectorAll('.view-prev').forEach(prev => { - prev.classList.remove('view-prev'); - prev.classList.add('view-next'); - }); - active?.view.classList.add(nextContainerIndex < activeContainerIndex ? 'view-next' : 'view-prev'); - activeContainerIndex = nextContainerIndex; - }); -} + // subscribe and change view function route(path) { diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index 5ba1f1a..0000000 --- a/src/utils.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * throttle and debounce given function in regular time interval, - * but with the difference that the last call will be debounced and therefore never missed. - * @param {*} function to throttle and debounce - * @param {*} time desired interval to execute function - * @returns callback - */ -export const bounce = (fn, time) => { - let throttle; - let debounce; - return (/*...args*/) => { - if (throttle) { - clearTimeout(debounce); - debounce = setTimeout(() => fn(/*...args*/), time); - return; - } - fn(/*...args*/); - throttle = setTimeout(() => { - throttle = false; - }, time); - }; -}; diff --git a/src/utils/dom.ts b/src/utils/dom.ts new file mode 100644 index 0000000..cd00032 --- /dev/null +++ b/src/utils/dom.ts @@ -0,0 +1,129 @@ +type Attributes = { + [key: string]: string | number; +} & { + data?: { + [key: string]: string | number; + } +}; + +/** + * example usage: + * + * const props = {className: 'btn', onclick: async (e) => alert('hi')}; + * const btn = elem('button', props, ['download']); + * document.body.append(btn); + * + * @param {string} name + * @param {HTMLElement.prototype} props + * @param {Array} children + * @return HTMLElement + */ +export const elem = ( + name: keyof HTMLElementTagNameMap, + attrs: Attributes = {}, + children: Array | string = [] +) => { + const {data, ...props} = attrs; + const el = document.createElement(name); + Object.assign(el, props); + if (Array.isArray(children)) { + el.append(...children); + } else { + const childType = typeof children; + if (childType === 'number' || childType === 'string') { + el.append(children); + } else { + console.error('call me'); + } + } + if (data) { + Object.entries(data).forEach(([key, value]) => { + el.dataset[key] = value as string; + }); + } + return el; +}; + +export const isValidURL = (url: URL) => { + if (!['http:', 'https:'].includes(url.protocol)) { + return false; + } + if (!['', '443', '80'].includes(url.port)) { + return false; + } + if (url.hostname === 'localhost') { + return false; + } + const lastDot = url.hostname.lastIndexOf('.'); + if (lastDot < 1) { + return false; + } + if (url.hostname.slice(lastDot) === '.local') { + return false; + } + if (url.hostname.slice(lastDot + 1).match(/^[\d]+$/)) { // there should be no tld with numbers, possible ipv4 + return false; + } + if (url.hostname.includes(':')) { // possibly an ipv6 addr; certainly an invalid hostname + return false; + } + return true; +} + +/** + * example usage: + * + * const [content, {firstLink}] = parseTextContent('Hi
click https://nostr.ch/'); + * + * @param {string} content + * @returns [Array, {firstLink: href}] + */ +export const parseTextContent = ( + content: string, +): [ + Array, + {firstLink: string | undefined}, +] => { + let firstLink: string | undefined; + const parsedContent = content + .trim() + .replaceAll(/\n{3,}/g, '\n\n') + .split('\n') + .map(line => { + const words = line.split(/\s/); + return words.map(word => { + if (word.match(/^ln(tbs?|bcr?t?)[a-z0-9]+$/g)) { + return elem('a', { + href: `lightning:${word}` + }, `lightning:${word.slice(0, 24)}…`); + } + if (!word.match(/^(https?:\/\/|www\.)\S*/)) { + return word; + } + try { + if (!word.startsWith('http')) { + word = 'https://' + word; + } + const url = new URL(word); + if (!isValidURL(url)) { + return word; + } + firstLink = firstLink || url.href; + return elem('a', { + href: url.href, + target: '_blank', + rel: 'noopener noreferrer' + }, url.href.slice(url.protocol.length + 2)); + } catch (err) { + return word; + } + }) + .reduce((acc, word) => [...acc, word, ' '], []); + }) + .reduce((acc, words) => [...acc, ...words, elem('br')], []); + + return [ + parsedContent, + {firstLink} + ]; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..5ca6ee9 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export {elem, parseTextContent} from './dom'; +export {bounce, dateTime, formatTime} from './time'; diff --git a/src/timeutil.js b/src/utils/time.ts similarity index 66% rename from src/timeutil.js rename to src/utils/time.ts index f798f83..a44d035 100644 --- a/src/timeutil.js +++ b/src/utils/time.ts @@ -1,8 +1,34 @@ +/** + * throttle and debounce given function in regular time interval, + * but with the difference that the last call will be debounced and therefore never missed. + * @param {*} function to throttle and debounce + * @param {*} time desired interval to execute function + * @returns callback + */ +export const bounce = ( + fn: () => void, + time: number, +) => { + let throttle; + let debounce; + return (/*...args*/) => { + if (throttle) { + clearTimeout(debounce); + debounce = setTimeout(() => fn(/*...args*/), time); + return; + } + fn(/*...args*/); + throttle = setTimeout(() => { + throttle = false; + }, time); + }; +}; + /** * Intl.DateTimeFormat object - * + * * example: - * + * * console.log(dateTime.format(new Date())); */ export const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */, { @@ -12,17 +38,20 @@ export const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */ /** * format time relative to now, such as 5min ago - * - * @param {Date} time + * + * @param {Date} time * @param {string} locale * @returns string - * + * * example: - * + * * console.log(timeAgo(new Date(Date.now() - 10000))); - * + * */ -const timeAgo = (time, locale = 'en') => { +const timeAgo = ( + time: Date, + locale: string = 'en', +) => { const relativeTime = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'long', @@ -55,7 +84,7 @@ const timeAgo = (time, locale = 'en') => { * @param {time} date object to format * @return string */ -export const formatTime = (time) => { +export const formatTime = (time: Date) => { const yesterday = new Date(Date.now() - (24 * 60 * 60 * 1000)); if (time > yesterday) { return timeAgo(time); diff --git a/src/view.ts b/src/view.ts new file mode 100644 index 0000000..03e0180 --- /dev/null +++ b/src/view.ts @@ -0,0 +1,76 @@ +import {elem} from './utils'; + +type Container = { + id: string; + view: HTMLSelectElement; + content: HTMLDivElement; + dom: { + [eventId: string]: HTMLElement + } +}; + +const containers: Array = []; + +let activeContainerIndex = -1; + +export const getViewContent = () => containers[activeContainerIndex]?.content; + +export const clearView = () => { + // TODO: this is clears the current view, but it should probably do this for all views + const domMap = containers[activeContainerIndex]?.dom; + Object.keys(domMap).forEach(eventId => delete domMap[eventId]); + getViewContent().replaceChildren(); +}; + +export const getViewElem = (eventId: string) => { + return containers[activeContainerIndex]?.dom[eventId]; +}; + +export const setViewElem = (eventId: string, node: HTMLElement) => { + const container = containers[activeContainerIndex]; + if (container) { + container.dom[eventId] = node; + } + return node; +}; + +const mainContainer = document.querySelector('main'); + +const getContainer = (route: string) => { + let container = containers.find(c => c.id === route); + if (container) { + return container; + } + const content = elem('div', {className: 'content'}); + const view = elem('section', {className: 'view'}, [content]); + mainContainer?.append(view); + container = {id: route, view, content, dom: {}}; + containers.push(container); + return container; +}; + +export const view = (route: string) => { + const active = containers[activeContainerIndex]; + active?.view.classList.remove('view-active'); + const nextContainer = getContainer(route); + const nextContainerIndex = containers.indexOf(nextContainer); + if (nextContainerIndex === activeContainerIndex) { + return; + } + if (active) { + nextContainer.view.classList.add('view-next'); + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + nextContainer.view.classList.remove('view-next', 'view-prev'); + nextContainer.view.classList.add('view-active'); + }); + // // console.log(activeContainerIndex, nextContainerIndex); + getViewContent()?.querySelectorAll('.view-prev').forEach(prev => { + prev.classList.remove('view-prev'); + prev.classList.add('view-next'); + }); + active?.view.classList.add(nextContainerIndex < activeContainerIndex ? 'view-next' : 'view-prev'); + activeContainerIndex = nextContainerIndex; + }); +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2ebb6d9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "target": "es2021" + }, + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} \ No newline at end of file -- 2.46.2 From 2d46687e120550a99384a8720067b6eabb9d88e5 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 4 Mar 2023 18:54:03 +0100 Subject: [PATCH 07/34] refactor: type events.ts, url.ts and crypto.ts --- src/events.ts | 61 ++++++++++++++ src/main.js | 106 ++---------------------- src/{cryptoutils.js => utils/crypto.ts} | 12 +-- src/utils/index.ts | 2 + src/utils/url.ts | 39 +++++++++ src/worker.js | 2 +- tsconfig.json | 5 +- 7 files changed, 120 insertions(+), 107 deletions(-) create mode 100644 src/events.ts rename src/{cryptoutils.js => utils/crypto.ts} (63%) create mode 100644 src/utils/url.ts diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..a4d90eb --- /dev/null +++ b/src/events.ts @@ -0,0 +1,61 @@ +import {Event} from 'nostr-tools'; +import {zeroLeadingBitsCount} from './utils'; + +export const isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention'; +export const hasEventTag = (tag: string[]) => tag[0] === 'e'; + +/** + * 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 + */ +export const validatePow = (evt: Event) => { + 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; +} + +export const sortByCreatedAt = (evt1: Event, evt2: Event) => { + if (evt1.created_at === evt2.created_at) { + // console.log('TODO: OMG exactly at the same time, figure out how to sort then', evt1, evt2); + } + return evt1.created_at > evt2.created_at ? -1 : 1; +}; + +export const sortEventCreatedAt = (created_at: number) => ( + {created_at: a}: Event, + {created_at: b}: Event, +) => ( + Math.abs(a - created_at) < Math.abs(b - created_at) ? -1 : 1 +); + +const isReply = ([tag, , , marker]: string[]) => tag === 'e' && marker !== 'mention'; + +/** + * find reply-to ID according to nip-10, find marked reply or root tag or + * fallback to positional (last) e tag or return null + * @param {event} evt + * @returns replyToID | null + */ +export const getReplyTo = (evt: Event): string | null => { + const eventTags = evt.tags.filter(isReply); + const withReplyMarker = eventTags.filter(([, , , marker]) => marker === 'reply'); + if (withReplyMarker.length === 1) { + return withReplyMarker[0][1]; + } + const withRootMarker = eventTags.filter(([, , , marker]) => marker === 'root'); + if (withReplyMarker.length === 0 && withRootMarker.length === 1) { + return withRootMarker[0][1]; + } + // fallback to deprecated positional 'e' tags (nip-10) + const lastTag = eventTags.at(-1); + return lastTag ? lastTag[1] : null; +}; diff --git a/src/main.js b/src/main.js index 31e9a3c..edc1dc7 100644 --- a/src/main.js +++ b/src/main.js @@ -1,9 +1,9 @@ import {generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent} from 'nostr-tools'; import {sub24hFeed, subNote, subProfile} from './subscriptions' import {publish} from './relays'; +import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; -import {zeroLeadingBitsCount} from './cryptoutils.js'; -import {bounce, dateTime, elem, formatTime, parseTextContent} from './utils'; +import {bounce, dateTime, elem, formatTime, getHost, getNoxyUrl, isWssUrl, parseTextContent, zeroLeadingBitsCount} from './utils'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ function onEvent(evt, relay) { @@ -39,12 +39,6 @@ let pubkey = localStorage.getItem('pub_key') || (() => { const textNoteList = []; // could use indexDB const eventRelayMap = {}; // eventId: [relay1, relay2] -const hasEventTag = tag => tag[0] === 'e'; -const isReply = ([tag, , , marker]) => tag === 'e' && marker !== 'mention'; -const isMention = ([tag, , , marker]) => tag === 'e' && marker === 'mention'; -const hasEnoughPOW = ([tag, , commitment]) => { - return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(note.id) >= fitlerDifficulty; -}; const renderNote = (evt, i, sortedFeeds) => { if (getViewElem(evt.id)) { // note already in view return; @@ -58,13 +52,17 @@ const renderNote = (evt, i, sortedFeeds) => { setViewElem(evt.id, article); }; +const hasEnoughPOW = ([tag, , commitment], eventId) => { + return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(eventId) >= fitlerDifficulty; +}; + const renderFeed = bounce(() => { const now = Math.floor(Date.now() * 0.001); textNoteList // dont render notes from the future .filter(note => note.created_at <= now) // if difficulty filter is configured dont render notes with too little pow - .filter(note => !fitlerDifficulty || note.tags.some(hasEnoughPOW)) + .filter(note => !fitlerDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id))) .sort(sortByCreatedAt) .reverse() .forEach(renderNote); @@ -161,13 +159,6 @@ function handleReaction(evt, relay) { const restoredReplyTo = localStorage.getItem('reply_to'); -const sortByCreatedAt = (evt1, evt2) => { - if (evt1.created_at === evt2.created_at) { - // console.log('TODO: OMG exactly at the same time, figure out how to sort then', evt1, evt2); - } - return evt1.created_at > evt2.created_at ? -1 : 1; -}; - function rerenderFeed() { clearView(); renderFeed(); @@ -179,17 +170,6 @@ setInterval(() => { }); }, 10000); -const getNoxyUrl = (type, url, id, relay) => { - if (!isHttpUrl(url)) { - return false; - } - const link = new URL(`https://noxy.nostr.ch/${type}`); - link.searchParams.set('id', id); - link.searchParams.set('relay', relay); - link.searchParams.set('url', url); - return link; -} - const fetchQue = []; let fetchPending; const fetchNext = (href, id, relay) => { @@ -300,21 +280,6 @@ function createTextNote(evt, relay) { ], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); } -const sortEventCreatedAt = (created_at) => ( - {created_at: a}, - {created_at: b}, -) => ( - Math.abs(a - created_at) < Math.abs(b - created_at) ? -1 : 1 -); - -function isWssUrl(string) { - try { - return 'wss:' === new URL(string).protocol; - } catch (err) { - return false; - } -} - function handleRecommendServer(evt, relay) { if (getViewElem(evt.id) || !isWssUrl(evt.content)) { return; @@ -326,7 +291,7 @@ function handleRecommendServer(evt, relay) { const closestTextNotes = textNoteList .filter(note => !fitlerDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && commitment >= fitlerDifficulty)) .sort(sortEventCreatedAt(evt.created_at)); - getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed + getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed } setViewElem(evt.id, art); } @@ -450,22 +415,6 @@ function setMetadata(evt, relay, content) { // } } -function isHttpUrl(string) { - try { - return ['http:', 'https:'].includes(new URL(string).protocol); - } catch (err) { - return false; - } -} - -const getHost = (url) => { - try { - return new URL(url).host; - } catch(err) { - return err; - } -} - const elemCanvas = (text) => { const canvas = elem('canvas', {height: 80, width: 80, data: {pubkey: text}}); const context = canvas.getContext('2d'); @@ -499,26 +448,6 @@ function getMetadata(evt, relay) { return {host, img, name, time, userName}; } -/** - * find reply-to ID according to nip-10, find marked reply or root tag or - * fallback to positional (last) e tag or return null - * @param {event} evt - * @returns replyToID | null - */ -function getReplyTo(evt) { - const eventTags = evt.tags.filter(isReply); - const withReplyMarker = eventTags.filter(([, , , marker]) => marker === 'reply'); - if (withReplyMarker.length === 1) { - return withReplyMarker[0][1]; - } - const withRootMarker = eventTags.filter(([, , , marker]) => marker === 'root'); - if (withReplyMarker.length === 0 && withRootMarker.length === 1) { - return withRootMarker[0][1]; - } - // fallback to deprecated positional 'e' tags (nip-10) - return eventTags.length ? eventTags.at(-1)[1] : null; -} - const writeForm = document.querySelector('#writeForm'); const elemShrink = () => { @@ -973,25 +902,6 @@ function promptError(error, options = {}) { 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 diff --git a/src/cryptoutils.js b/src/utils/crypto.ts similarity index 63% rename from src/cryptoutils.js rename to src/utils/crypto.ts index 2c2b265..9cc1d5a 100644 --- a/src/cryptoutils.js +++ b/src/utils/crypto.ts @@ -1,19 +1,19 @@ /** - * 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) => { + * 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: string) => { let count = 0; for (let i = 0; i < 64; i += 2) { const hexbyte = hex32.slice(i, i + 2); // grab next byte - if (hexbyte == '00') { + 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' ) { + if (bits[b] === '1' ) { break; // reached non-zero bit; stop } count += 1; diff --git a/src/utils/index.ts b/src/utils/index.ts index 5ca6ee9..80973cc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,4 @@ +export {zeroLeadingBitsCount} from './crypto'; export {elem, parseTextContent} from './dom'; export {bounce, dateTime, formatTime} from './time'; +export {getHost, getNoxyUrl, isHttpUrl, isWssUrl} from './url'; diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000..0309cd9 --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,39 @@ +export const getHost = (url: string) => { + try { + return new URL(url).host; + } catch(err) { + return err; + } +}; + +export const isHttpUrl = (url: string) => { + try { + return ['http:', 'https:'].includes(new URL(url).protocol); + } catch (err) { + return false; + } +}; + +export const isWssUrl = (url: string) => { + try { + return 'wss:' === new URL(url).protocol; + } catch (err) { + return false; + } +}; + +export const getNoxyUrl = ( + type: 'data' | 'meta', + url: string, + id: string, + relay: string, +) => { + if (!isHttpUrl(url)) { + return false; + } + const link = new URL(`https://noxy.nostr.ch/${type}`); + link.searchParams.set('id', id); + link.searchParams.set('relay', relay); + link.searchParams.set('url', url); + return link; +}; diff --git a/src/worker.js b/src/worker.js index 2272cd1..97c14aa 100644 --- a/src/worker.js +++ b/src/worker.js @@ -1,5 +1,5 @@ import {getEventHash} from 'nostr-tools'; -import {zeroLeadingBitsCount} from './cryptoutils.js'; +import {zeroLeadingBitsCount} from './utils/crypto'; function mine(event, difficulty, timeout = 5) { const max = 256; // arbitrary diff --git a/tsconfig.json b/tsconfig.json index 2ebb6d9..6e20659 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "target": "es2021" + "moduleResolution": "node", + "target": "es2022" }, "exclude": ["node_modules", "dist", "**/*.test.ts"] -} \ No newline at end of file +} -- 2.46.2 From 489a26042773af8a7bcfff6c7b18a483c7ca6350 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 5 Mar 2023 09:22:31 +0100 Subject: [PATCH 08/34] refactor: type elem and enforce inferred generic type typed elem so that it returns the exact type of the HTMLElement, and that name must be a key of HTMLElementTagNameMap. example: elem('form'); // returns HTMLFormElement elem('abc'); // not assignable to parameter of type 'keyof HTMLElementTagNameMap' --- src/main.js | 30 +++------------------------ src/utils/dom.ts | 51 +++++++++++++++++++++++++++++++++++++++++----- src/utils/index.ts | 2 +- src/view.ts | 2 +- 4 files changed, 51 insertions(+), 34 deletions(-) diff --git a/src/main.js b/src/main.js index edc1dc7..221d00f 100644 --- a/src/main.js +++ b/src/main.js @@ -3,7 +3,7 @@ import {sub24hFeed, subNote, subProfile} from './subscriptions' import {publish} from './relays'; import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; -import {bounce, dateTime, elem, formatTime, getHost, getNoxyUrl, isWssUrl, parseTextContent, zeroLeadingBitsCount} from './utils'; +import {bounce, dateTime, elem, elemCanvas, elemShrink, formatTime, getHost, getNoxyUrl, isWssUrl, parseTextContent, zeroLeadingBitsCount} from './utils'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ function onEvent(evt, relay) { @@ -415,22 +415,6 @@ function setMetadata(evt, relay, content) { // } } -const elemCanvas = (text) => { - const canvas = elem('canvas', {height: 80, width: 80, data: {pubkey: text}}); - const context = canvas.getContext('2d'); - const color = `#${text.slice(0, 6)}`; - context.fillStyle = color; - context.fillRect(0, 0, 80, 80); - context.fillStyle = '#111'; - context.fillRect(0, 50, 80, 32); - context.font = 'bold 18px monospace'; - if (color === '#000000') { - context.fillStyle = '#fff'; - } - context.fillText(text.slice(0, 8), 2, 46); - return canvas; -} - function getMetadata(evt, relay) { const host = getHost(relay); const user = userList.find(user => user.pubkey === evt.pubkey); @@ -450,20 +434,12 @@ function getMetadata(evt, relay) { const writeForm = document.querySelector('#writeForm'); -const elemShrink = () => { - const height = writeInput.style.height || writeInput.getBoundingClientRect().height; - const shrink = elem('div', {className: 'shrink-out'}); - shrink.style.height = `${height}px`; - shrink.addEventListener('animationend', () => shrink.remove(), {once: true}); - return shrink; -} - writeInput.addEventListener('focusout', () => { const reply_to = localStorage.getItem('reply_to'); if (reply_to && writeInput.value === '') { writeInput.addEventListener('transitionend', (event) => { if (!reply_to || reply_to === localStorage.getItem('reply_to') && !writeInput.style.height) { // should prob use some class or data-attr instead of relying on height - writeForm.after(elemShrink()); + writeForm.after(elemShrink(writeInput)); writeForm.remove(); localStorage.removeItem('reply_to'); } @@ -472,7 +448,7 @@ writeInput.addEventListener('focusout', () => { }); function appendReplyForm(el) { - writeForm.before(elemShrink()); + writeForm.before(elemShrink(writeInput)); writeInput.blur(); writeInput.style.removeProperty('height'); el.after(writeForm); diff --git a/src/utils/dom.ts b/src/utils/dom.ts index cd00032..a697702 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -18,11 +18,11 @@ type Attributes = { * @param {Array} children * @return HTMLElement */ -export const elem = ( - name: keyof HTMLElementTagNameMap, - attrs: Attributes = {}, - children: Array | string = [] -) => { +export const elem = ( + name: Extract, + attrs: Attributes = {}, // TODO optional + children: Array | string = [], // TODO optional +): HTMLElementTagNameMap[Name] => { const {data, ...props} = attrs; const el = document.createElement(name); Object.assign(el, props); @@ -127,3 +127,44 @@ export const parseTextContent = ( {firstLink} ]; }; + +/** + * creates a small profile image + * @param text to pass pubkey + * @returns HTMLCanvasElement | null + */ +export const elemCanvas = (text: string) => { + const canvas = elem('canvas', { + height: 80, + width: 80, + data: {pubkey: text} + }); + const context = canvas.getContext('2d'); + if (!context) { + return null; + } + const color = `#${text.slice(0, 6)}`; + context.fillStyle = color; + context.fillRect(0, 0, 80, 80); + context.fillStyle = '#111'; + context.fillRect(0, 50, 80, 32); + context.font = 'bold 18px monospace'; + if (color === '#000000') { + context.fillStyle = '#fff'; + } + context.fillText(text.slice(0, 8), 2, 46); + return canvas; +}; + +/** + * creates a placeholder element that animates the height to 0 + * @param element to get the initial height from + * @returns HTMLDivElement + */ +export const elemShrink = (el: HTMLElement) => { + const height = el.style.height || el.getBoundingClientRect().height; + const shrink = elem('div', {className: 'shrink-out'}); + shrink.style.height = `${height}px`; + shrink.addEventListener('animationend', () => shrink.remove(), {once: true}); + return shrink; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 80973cc..de96166 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ export {zeroLeadingBitsCount} from './crypto'; -export {elem, parseTextContent} from './dom'; +export {elem, elemCanvas, elemShrink, parseTextContent} from './dom'; export {bounce, dateTime, formatTime} from './time'; export {getHost, getNoxyUrl, isHttpUrl, isWssUrl} from './url'; diff --git a/src/view.ts b/src/view.ts index 03e0180..8cbda1d 100644 --- a/src/view.ts +++ b/src/view.ts @@ -2,7 +2,7 @@ import {elem} from './utils'; type Container = { id: string; - view: HTMLSelectElement; + view: HTMLElement; content: HTMLDivElement; dom: { [eventId: string]: HTMLElement -- 2.46.2 From 6e404eac6b889129dbeb46c4ce2205dffdb2d5a3 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 5 Mar 2023 20:45:20 +0100 Subject: [PATCH 09/34] refactor: type element attributes use attributes of html element type. so that the following example is correctly typed: elem('input', { className: 'foo', hidden: false, onclick: () => alert('hi'), tabIndex: 1, valueAsNumber: 1, }); but this fails as foo is no valid attribute on div element: elem('div', {foo: 'bar'}); --- src/utils/dom.ts | 54 +++++++++++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/src/utils/dom.ts b/src/utils/dom.ts index a697702..7b3c41b 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -1,11 +1,11 @@ -type Attributes = { - [key: string]: string | number; -} & { - data?: { +type DataAttributes = { + data: { [key: string]: string | number; } }; +type Attributes = Partial; + /** * example usage: * @@ -15,31 +15,43 @@ type Attributes = { * * @param {string} name * @param {HTMLElement.prototype} props - * @param {Array} children + * @param {Array | string | number} children * @return HTMLElement */ export const elem = ( name: Extract, - attrs: Attributes = {}, // TODO optional - children: Array | string = [], // TODO optional + attrs?: Attributes, + children?: Array | string | number, ): HTMLElementTagNameMap[Name] => { - const {data, ...props} = attrs; const el = document.createElement(name); - Object.assign(el, props); - if (Array.isArray(children)) { - el.append(...children); - } else { - const childType = typeof children; - if (childType === 'number' || childType === 'string') { - el.append(children); - } else { - console.error('call me'); + if (attrs) { + const {data, ...props} = attrs; + Object.assign(el, props); + if (data) { + Object.entries(data).forEach(([key, value]) => { + el.dataset[key] = value as string; + }); } } - if (data) { - Object.entries(data).forEach(([key, value]) => { - el.dataset[key] = value as string; - }); + if (children != null) { + if (Array.isArray(children)) { + el.append(...children); + } else { + switch (typeof children) { + case 'number': + el.append(`${children}`); + break; + case 'string': + el.append(children); + break; + default: + if (children instanceof Element) { + el.append(children); + break; + } + console.error(`expected element, string or number but got ${typeof children}`, children); + } + } } return el; }; -- 2.46.2 From 33dd40bae554c0ee856f0e146602a1588f60e097 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 5 Mar 2023 20:51:51 +0100 Subject: [PATCH 10/34] typescript --- src/utils/time.ts | 6 +++--- tsconfig.json | 13 ++++++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/utils/time.ts b/src/utils/time.ts index a44d035..8adf84b 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -9,8 +9,8 @@ export const bounce = ( fn: () => void, time: number, ) => { - let throttle; - let debounce; + let throttle: number | undefined; + let debounce: number | undefined; return (/*...args*/) => { if (throttle) { clearTimeout(debounce); @@ -19,7 +19,7 @@ export const bounce = ( } fn(/*...args*/); throttle = setTimeout(() => { - throttle = false; + clearTimeout(throttle); }, time); }; }; diff --git a/tsconfig.json b/tsconfig.json index 6e20659..ec17c1b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,18 @@ { "compilerOptions": { + "alwaysStrict": true, "moduleResolution": "node", + "noImplicitAny": true, + "noImplicitThis": true, + "strictBindCallApply": true, + "strictFunctionTypes": false, + "strictNullChecks": true, "target": "es2022" }, - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "exclude": [ + "dist", + "esbuildconf.js", + "node_modules", + "**/*.test.ts" + ] } -- 2.46.2 From 495e75584416635e404df16f655545a9618629a7 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 5 Mar 2023 23:07:01 +0100 Subject: [PATCH 11/34] styles: move stylesheets to styles --- esbuildconf.js | 2 +- src/index.html | 2 +- src/{ => styles}/cards.css | 0 src/{ => styles}/error.css | 0 src/{ => styles}/form.css | 0 src/{ => styles}/main.css | 0 src/{ => styles}/view.css | 0 src/{ => styles}/write.css | 0 8 files changed, 2 insertions(+), 2 deletions(-) rename src/{ => styles}/cards.css (100%) rename src/{ => styles}/error.css (100%) rename src/{ => styles}/form.css (100%) rename src/{ => styles}/main.css (100%) rename src/{ => styles}/view.css (100%) rename src/{ => styles}/write.css (100%) diff --git a/esbuildconf.js b/esbuildconf.js index 59e5fe4..f2a783c 100644 --- a/esbuildconf.js +++ b/esbuildconf.js @@ -17,7 +17,7 @@ export const options = { 'src/assets/star-fill.svg', 'src/favicon.ico', 'src/index.html', - 'src/main.css', + 'src/styles/main.css', 'src/main.js', 'src/manifest.json', 'src/worker.js', diff --git a/src/index.html b/src/index.html index 4027258..8567e2b 100644 --- a/src/index.html +++ b/src/index.html @@ -6,7 +6,7 @@ nostr - + diff --git a/src/cards.css b/src/styles/cards.css similarity index 100% rename from src/cards.css rename to src/styles/cards.css diff --git a/src/error.css b/src/styles/error.css similarity index 100% rename from src/error.css rename to src/styles/error.css diff --git a/src/form.css b/src/styles/form.css similarity index 100% rename from src/form.css rename to src/styles/form.css diff --git a/src/main.css b/src/styles/main.css similarity index 100% rename from src/main.css rename to src/styles/main.css diff --git a/src/view.css b/src/styles/view.css similarity index 100% rename from src/view.css rename to src/styles/view.css diff --git a/src/write.css b/src/styles/write.css similarity index 100% rename from src/write.css rename to src/styles/write.css -- 2.46.2 From 70fb0da35aaff092df3fa365a6798edf0787c650 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 11 Mar 2023 11:31:06 +0100 Subject: [PATCH 12/34] utils: import directly from utils it is not worth to import everything from utils, as there are too many functions, better import them directly from each module. --- src/events.ts | 2 +- src/main.js | 5 ++++- src/utils/index.ts | 4 ---- src/view.ts | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) delete mode 100644 src/utils/index.ts diff --git a/src/events.ts b/src/events.ts index a4d90eb..8ed8064 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,5 +1,5 @@ import {Event} from 'nostr-tools'; -import {zeroLeadingBitsCount} from './utils'; +import {zeroLeadingBitsCount} from './utils/crypto'; export const isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention'; export const hasEventTag = (tag: string[]) => tag[0] === 'e'; diff --git a/src/main.js b/src/main.js index 221d00f..940d882 100644 --- a/src/main.js +++ b/src/main.js @@ -1,9 +1,12 @@ import {generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent} from 'nostr-tools'; +import {zeroLeadingBitsCount} from './utils/crypto'; +import {elem, elemCanvas, elemShrink, parseTextContent} from './utils/dom'; +import {bounce, dateTime, formatTime} from './utils/time'; +import {getHost, getNoxyUrl, isWssUrl} from './utils/url'; import {sub24hFeed, subNote, subProfile} from './subscriptions' import {publish} from './relays'; import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; -import {bounce, dateTime, elem, elemCanvas, elemShrink, formatTime, getHost, getNoxyUrl, isWssUrl, parseTextContent, zeroLeadingBitsCount} from './utils'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ function onEvent(evt, relay) { diff --git a/src/utils/index.ts b/src/utils/index.ts deleted file mode 100644 index de96166..0000000 --- a/src/utils/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export {zeroLeadingBitsCount} from './crypto'; -export {elem, elemCanvas, elemShrink, parseTextContent} from './dom'; -export {bounce, dateTime, formatTime} from './time'; -export {getHost, getNoxyUrl, isHttpUrl, isWssUrl} from './url'; diff --git a/src/view.ts b/src/view.ts index 8cbda1d..79d8b53 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,4 +1,4 @@ -import {elem} from './utils'; +import {elem} from './utils/dom'; type Container = { id: string; -- 2.46.2 From 23188c161f5b624b04d0ae4b5966d13d48b34c46 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 11 Mar 2023 11:54:55 +0100 Subject: [PATCH 13/34] utils: move lock and unlock scroll functions to utils --- src/main.js | 5 +---- src/utils/dom.ts | 6 ++++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main.js b/src/main.js index 940d882..9efb74a 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,6 @@ import {generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; -import {elem, elemCanvas, elemShrink, parseTextContent} from './utils/dom'; +import {elem, elemCanvas, elemShrink, lockScroll, parseTextContent, unlockScroll} from './utils/dom'; import {bounce, dateTime, formatTime} from './utils/time'; import {getHost, getNoxyUrl, isWssUrl} from './utils/url'; import {sub24hFeed, subNote, subProfile} from './subscriptions' @@ -463,9 +463,6 @@ function appendReplyForm(el) { requestAnimationFrame(() => writeInput.focus()); } -const lockScroll = () => document.body.style.overflow = 'hidden'; -const unlockScroll = () => document.body.style.removeProperty('overflow'); - let fitlerDifficulty = JSON.parse(localStorage.getItem('filter_difficulty')) ?? 0; const filterDifficultyInput = document.querySelector('#filterDifficulty'); const filterDifficultyDisplay = document.querySelector('[data-display="filter_difficulty"]'); diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 7b3c41b..06b8cd0 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -56,6 +56,12 @@ export const elem = ( return el; }; +/** freeze global page scrolling */ +export const lockScroll = () => document.body.style.overflow = 'hidden'; + +/** free global page scrolling */ +export const unlockScroll = () => document.body.style.removeProperty('overflow'); + export const isValidURL = (url: URL) => { if (!['http:', 'https:'].includes(url.protocol)) { return false; -- 2.46.2 From cadd0302a59e19cdbad72c7365bd71ae75b21c7d Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 11 Mar 2023 12:11:08 +0100 Subject: [PATCH 14/34] utils: cleanup and move isvalidurl to utils/url --- src/utils/dom.ts | 28 ++-------------------------- src/utils/url.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 06b8cd0..81e9d7f 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -1,3 +1,5 @@ +import {isValidURL} from './url'; + type DataAttributes = { data: { [key: string]: string | number; @@ -62,32 +64,6 @@ export const lockScroll = () => document.body.style.overflow = 'hidden'; /** free global page scrolling */ export const unlockScroll = () => document.body.style.removeProperty('overflow'); -export const isValidURL = (url: URL) => { - if (!['http:', 'https:'].includes(url.protocol)) { - return false; - } - if (!['', '443', '80'].includes(url.port)) { - return false; - } - if (url.hostname === 'localhost') { - return false; - } - const lastDot = url.hostname.lastIndexOf('.'); - if (lastDot < 1) { - return false; - } - if (url.hostname.slice(lastDot) === '.local') { - return false; - } - if (url.hostname.slice(lastDot + 1).match(/^[\d]+$/)) { // there should be no tld with numbers, possible ipv4 - return false; - } - if (url.hostname.includes(':')) { // possibly an ipv6 addr; certainly an invalid hostname - return false; - } - return true; -} - /** * example usage: * diff --git a/src/utils/url.ts b/src/utils/url.ts index 0309cd9..4503605 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -37,3 +37,29 @@ export const getNoxyUrl = ( link.searchParams.set('url', url); return link; }; + +export const isValidURL = (url: URL) => { + if (!['http:', 'https:'].includes(url.protocol)) { + return false; + } + if (!['', '443', '80'].includes(url.port)) { + return false; + } + if (url.hostname === 'localhost') { + return false; + } + const lastDot = url.hostname.lastIndexOf('.'); + if (lastDot < 1) { + return false; + } + if (url.hostname.slice(lastDot) === '.local') { + return false; + } + if (url.hostname.slice(lastDot + 1).match(/^[\d]+$/)) { // there should be no tld with numbers, possible ipv4 + return false; + } + if (url.hostname.includes(':')) { // possibly an ipv6 addr; certainly an invalid hostname + return false; + } + return true; +}; -- 2.46.2 From d654028a86d2ad4a346d62ac35857be34c35e425 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 11 Mar 2023 13:14:24 +0100 Subject: [PATCH 15/34] settings: refactor pubkey into config.pubkey global starting to move global application user config to settings.ts. plan is to only share user settings via the config object, with this all settings related ui can be moved out of main.js into its own module. --- src/main.js | 25 ++++++++----------------- src/settings.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 17 deletions(-) create mode 100644 src/settings.ts diff --git a/src/main.js b/src/main.js index 9efb74a..003629e 100644 --- a/src/main.js +++ b/src/main.js @@ -7,6 +7,7 @@ import {sub24hFeed, subNote, subProfile} from './subscriptions' import {publish} from './relays'; import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; +import {config} from './settings'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ function onEvent(evt, relay) { @@ -30,15 +31,6 @@ function onEvent(evt, relay) { } } -let pubkey = localStorage.getItem('pub_key') || (() => { - const privatekey = generatePrivateKey(); - const pubkey = getPublicKey(privatekey); - localStorage.setItem('private_key', privatekey); - localStorage.setItem('pub_key', pubkey); - return pubkey; -})(); - - const textNoteList = []; // could use indexDB const eventRelayMap = {}; // eventId: [relay1, relay2] @@ -152,7 +144,7 @@ function handleReaction(evt, relay) { const button = article.querySelector('button[name="star"]'); const reactions = button.querySelector('[data-reactions]'); reactions.textContent = reactionMap[eventId].length; - if (evt.pubkey === pubkey) { + if (evt.pubkey === config.pubkey) { const star = button.querySelector('img[src*="star"]'); star?.setAttribute('src', '/assets/star-fill.svg'); star?.setAttribute('title', getReactionList(eventId).join(' ')); @@ -240,7 +232,7 @@ function createTextNote(evt, relay) { // const isLongContent = evt.content.trimRight().length > 280; // const content = isLongContent ? evt.content.slice(0, 280) : evt.content; const hasReactions = reactionMap[evt.id]?.length > 0; - const didReact = hasReactions && !!reactionMap[evt.id].find(reaction => reaction.pubkey === pubkey); + const didReact = hasReactions && !!reactionMap[evt.id].find(reaction => reaction.pubkey === config.pubkey); const replyFeed = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : []; const [content, {firstLink}] = parseTextContent(evt.content); const body = elem('div', {className: 'mbox-body'}, [ @@ -506,7 +498,7 @@ async function upvote(eventId, eventPubkey) { 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 + pubkey: config.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), @@ -537,9 +529,8 @@ const onSendError = err => sendStatus.textContent = err.message; const publishBtn = document.querySelector('#publish'); writeForm.addEventListener('submit', async (e) => { e.preventDefault(); - // const pubkey = localStorage.getItem('pub_key'); const privatekey = localStorage.getItem('private_key'); - if (!pubkey || !privatekey) { + if (!config.pubkey || !privatekey) { return onSendError(new Error('no pubkey/privatekey')); } const content = writeInput.value.trimRight(); @@ -562,7 +553,7 @@ writeForm.addEventListener('submit', async (e) => { const newEvent = await powEvent({ kind: 1, content, - pubkey, + pubkey: config.pubkey, tags, created_at: Math.floor(Date.now() * 0.001), }, {difficulty, statusElem: sendStatus, timeout}).catch(console.warn); @@ -751,7 +742,7 @@ importBtn.addEventListener('click', () => { localStorage.setItem('pub_key', pubkeyInput); statusMessage.textContent = 'stored private and public key locally!'; statusMessage.hidden = false; - pubkey = pubkeyInput; + config.pubkey = pubkeyInput; } }); @@ -819,7 +810,7 @@ profileForm.addEventListener('submit', async (e) => { const form = new FormData(profileForm); const newProfile = await powEvent({ kind: 0, - pubkey, + pubkey: config.pubkey, content: JSON.stringify(Object.fromEntries(form)), tags: [], created_at: Math.floor(Date.now() * 0.001), diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..30a39d9 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,33 @@ +import {generatePrivateKey, getPublicKey} from 'nostr-tools'; + +let pubkey = ''; + +const loadOrGeneraateKeys = () => { + const storedPubKey = localStorage.getItem('pub_key'); + if (storedPubKey) { + return storedPubKey; + } + const privatekey = generatePrivateKey(); + const pubkey = getPublicKey(privatekey); + localStorage.setItem('private_key', privatekey); + localStorage.setItem('pub_key', pubkey); + return pubkey; +}; + +/** + * global config object + * config.pubkey, if not set loaded from localStorage or generate a new key + */ +export const config = { + get pubkey() { + if (!pubkey) { + pubkey = loadOrGeneraateKeys(); + } + return pubkey; + }, + set pubkey(value) { + console.info(`pubkey was set to ${value}`) + pubkey = value; + } +}; + -- 2.46.2 From b44fe108707de94484fbb88530f7f2ed4d5e7cf7 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 11 Mar 2023 18:00:53 +0100 Subject: [PATCH 16/34] system: move pow function and error overlay to system type and move powEvent and its error overlay to system.ts --- src/main.js | 94 ++------------------------------------ src/system.ts | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 91 deletions(-) create mode 100644 src/system.ts diff --git a/src/main.js b/src/main.js index 003629e..63bdb0d 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,6 @@ -import {generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent} from 'nostr-tools'; +import {generatePrivateKey, getPublicKey, nip19, signEvent} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; -import {elem, elemCanvas, elemShrink, lockScroll, parseTextContent, unlockScroll} from './utils/dom'; +import {elem, elemCanvas, elemShrink, parseTextContent} from './utils/dom'; import {bounce, dateTime, formatTime} from './utils/time'; import {getHost, getNoxyUrl, isWssUrl} from './utils/url'; import {sub24hFeed, subNote, subProfile} from './subscriptions' @@ -8,6 +8,7 @@ import {publish} from './relays'; import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; import {config} from './settings'; +import {powEvent} from './system'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ function onEvent(evt, relay) { @@ -835,92 +836,3 @@ profileForm.addEventListener('submit', async (e) => { }); } }); - -const errorOverlay = document.querySelector('#errorOverlay'); - -function promptError(error, options = {}) { - const {onAgain, onCancel} = options; - lockScroll(); - errorOverlay.replaceChildren( - elem('h1', {className: 'error-title'}, error), - elem('p', {}, 'time ran out finding a proof with the desired mining difficulty. either try again, lower the mining difficulty or increase the timeout in profile settings.'), - 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; -} - -/** - * 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({ - id: getEventHash(evt), - ...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(); - // promptError(msg.data.error, {}); - cancelBtn.removeEventListener('click', onCancel); - reject(err); - }; - - worker.postMessage({event: evt, difficulty, timeout}); - }); -} diff --git a/src/system.ts b/src/system.ts new file mode 100644 index 0000000..832b076 --- /dev/null +++ b/src/system.ts @@ -0,0 +1,124 @@ +import {Event, getEventHash, UnsignedEvent} from 'nostr-tools'; +import {elem, lockScroll, unlockScroll} from './utils/dom'; + +const errorOverlay = document.querySelector('section#errorOverlay') as HTMLElement; + +type PromptErrorOptions = { + onCancel?: () => void; + onRetry?: () => void; +}; + +/** + * Creates an error overlay, currently with hardcoded POW related message, this could be come a generic prompt + * @param error message + * @param options {onRetry, onCancel} callbacks + */ +const promptError = ( + error: string, + options: PromptErrorOptions, +) => { + const {onCancel, onRetry} = options; + lockScroll(); + errorOverlay.replaceChildren( + elem('h1', {className: 'error-title'}, error), + elem('p', {}, 'time ran out finding a proof with the desired mining difficulty. either try again, lower the mining difficulty or increase the timeout in profile settings.'), + elem('div', {className: 'buttons'}, [ + onCancel ? elem('button', {data: {action: 'close'}}, 'close') : '', + onRetry ? elem('button', {data: {action: 'again'}}, 'try again') : '', + ]), + ); + const handleOverlayClick = (e: MouseEvent) => { + if (e.target instanceof Element) { + const button = e.target.closest('button'); + if (button) { + switch(button.dataset.action) { + case 'close': + onCancel && onCancel(); + break; + case 'again': + onRetry && onRetry(); + break; + } + errorOverlay.removeEventListener('click', handleOverlayClick); + errorOverlay.hidden = true; + unlockScroll(); + } + } + }; + errorOverlay.addEventListener('click', handleOverlayClick); + errorOverlay.hidden = false; +} + +type PowEventOptions = { + difficulty: number; + statusElem: HTMLElement; + timeout: number; +}; + +type WorkerResponse = { + error: string; + event: Event; +}; + +type HashedEvent = UnsignedEvent & { + id: string; +}; + +/** + * 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'. + */ +export const powEvent = ( + evt: UnsignedEvent, + options: PowEventOptions +): Promise => { + const {difficulty, statusElem, timeout} = options; + if (difficulty === 0) { + return Promise.resolve({ + ...evt, + id: getEventHash(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(`mining kind ${evt.kind} event canceled`); + }; + cancelBtn.addEventListener('click', onCancel); + + worker.onmessage = (msg: MessageEvent) => { + worker.terminate(); + cancelBtn.removeEventListener('click', onCancel); + if (msg.data.error) { + promptError(msg.data.error, { + onCancel: () => reject(`mining kind ${evt.kind} event canceled`), + onRetry: async () => { + const result = await powEvent(evt, {difficulty, statusElem, timeout}).catch(console.warn); + resolve(result); + } + }) + } else { + resolve(msg.data.event); + } + }; + + worker.onerror = (err) => { + worker.terminate(); + // promptError(msg.data.error, {}); + cancelBtn.removeEventListener('click', onCancel); + reject(err); + }; + + worker.postMessage({event: evt, difficulty, timeout}); + }); +}; -- 2.46.2 From ca4594a7e744e47bd65bb21ae3f838d908bf5b67 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Fri, 17 Mar 2023 08:46:46 +0100 Subject: [PATCH 17/34] settings: move mining difficulty, filter and timeout configs type and move mining related configs to settings.ts. --- src/main.js | 59 ++++++++++++++++--------------------------- src/settings.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 42 deletions(-) diff --git a/src/main.js b/src/main.js index 63bdb0d..ebdb7ee 100644 --- a/src/main.js +++ b/src/main.js @@ -3,12 +3,12 @@ import {zeroLeadingBitsCount} from './utils/crypto'; import {elem, elemCanvas, elemShrink, parseTextContent} from './utils/dom'; import {bounce, dateTime, formatTime} from './utils/time'; import {getHost, getNoxyUrl, isWssUrl} from './utils/url'; +import {powEvent} from './system'; import {sub24hFeed, subNote, subProfile} from './subscriptions' import {publish} from './relays'; import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; import {config} from './settings'; -import {powEvent} from './system'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ function onEvent(evt, relay) { @@ -49,7 +49,7 @@ const renderNote = (evt, i, sortedFeeds) => { }; const hasEnoughPOW = ([tag, , commitment], eventId) => { - return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(eventId) >= fitlerDifficulty; + return tag === 'nonce' && commitment >= config.filterDifficulty && zeroLeadingBitsCount(eventId) >= config.filterDifficulty; }; const renderFeed = bounce(() => { @@ -58,7 +58,7 @@ const renderFeed = bounce(() => { // dont render notes from the future .filter(note => note.created_at <= now) // if difficulty filter is configured dont render notes with too little pow - .filter(note => !fitlerDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id))) + .filter(note => !config.filterDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id))) .sort(sortByCreatedAt) .reverse() .forEach(renderNote); @@ -155,10 +155,10 @@ function handleReaction(evt, relay) { const restoredReplyTo = localStorage.getItem('reply_to'); -function rerenderFeed() { +config.rerenderFeed = () => { clearView(); renderFeed(); -} +}; setInterval(() => { document.querySelectorAll('time[datetime]').forEach(timeElem => { @@ -285,7 +285,7 @@ function handleRecommendServer(evt, relay) { getViewContent().append(art); } else { const closestTextNotes = textNoteList - .filter(note => !fitlerDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && commitment >= fitlerDifficulty)) + .filter(note => !config.filterDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && commitment >= config.filterDifficulty)) // TODO: prob change to hasEnoughPOW .sort(sortEventCreatedAt(evt.created_at)); getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed } @@ -456,35 +456,6 @@ function appendReplyForm(el) { requestAnimationFrame(() => writeInput.focus()); } -let fitlerDifficulty = JSON.parse(localStorage.getItem('filter_difficulty')) ?? 0; -const filterDifficultyInput = document.querySelector('#filterDifficulty'); -const filterDifficultyDisplay = document.querySelector('[data-display="filter_difficulty"]'); -filterDifficultyInput.addEventListener('input', (e) => { - localStorage.setItem('filter_difficulty', filterDifficultyInput.valueAsNumber); - fitlerDifficulty = filterDifficultyInput.valueAsNumber; - filterDifficultyDisplay.textContent = filterDifficultyInput.value; - rerenderFeed(); -}); -filterDifficultyInput.value = fitlerDifficulty; -filterDifficultyDisplay.textContent = filterDifficultyInput.value; - -// 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 note = replyList.find(r => r.id === eventId) || textNoteList.find(n => n.id === (eventId)); const tags = [ @@ -503,7 +474,11 @@ async function upvote(eventId, eventPubkey) { content: '+', tags, created_at: Math.floor(Date.now() * 0.001), - }, {difficulty, statusElem, timeout}).catch(console.warn); + }, { + difficulty: config.difficulty, + statusElem, + timeout: config.timeout, + }).catch(console.warn); if (!newReaction) { statusElem.textContent = reactionMap[eventId]?.length; reactionBtn.disabled = false; @@ -557,7 +532,11 @@ writeForm.addEventListener('submit', async (e) => { pubkey: config.pubkey, tags, created_at: Math.floor(Date.now() * 0.001), - }, {difficulty, statusElem: sendStatus, timeout}).catch(console.warn); + }, { + difficulty: config.difficulty, + statusElem: sendStatus, + timeout: config.timeout, + }).catch(console.warn); if (!newEvent) { close(); return; @@ -815,7 +794,11 @@ profileForm.addEventListener('submit', async (e) => { content: JSON.stringify(Object.fromEntries(form)), tags: [], created_at: Math.floor(Date.now() * 0.001), - }, {difficulty, statusElem: profileStatus, timeout}).catch(console.warn); + }, { + difficulty: config.difficulty, + statusElem: profileStatus, + timeout: config.timeout, + }).catch(console.warn); if (!newProfile) { profileStatus.textContent = 'publishing profile data canceled'; profileStatus.hidden = false; diff --git a/src/settings.ts b/src/settings.ts index 30a39d9..407bdba 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,8 +1,8 @@ import {generatePrivateKey, getPublicKey} from 'nostr-tools'; -let pubkey = ''; +let pubkey: string = ''; -const loadOrGeneraateKeys = () => { +const loadOrGenerateKeys = () => { const storedPubKey = localStorage.getItem('pub_key'); if (storedPubKey) { return storedPubKey; @@ -14,6 +14,11 @@ const loadOrGeneraateKeys = () => { return pubkey; }; +let filterDifficulty: number = 0; +let difficulty: number = 16; +let timeout: number = 5; +let rerenderFeed: (() => void) | undefined; + /** * global config object * config.pubkey, if not set loaded from localStorage or generate a new key @@ -21,13 +26,67 @@ const loadOrGeneraateKeys = () => { export const config = { get pubkey() { if (!pubkey) { - pubkey = loadOrGeneraateKeys(); + pubkey = loadOrGenerateKeys(); } return pubkey; }, set pubkey(value) { - console.info(`pubkey was set to ${value}`) + console.info(`pubkey was set to ${value}`); pubkey = value; + }, + get filterDifficulty() { + return filterDifficulty; + }, + get difficulty() { + return difficulty; + }, + get timeout() { + return timeout; + }, + set rerenderFeed(value: () => void) { + rerenderFeed = value; } }; +const getNumberFromStorage = ( + item: string, + fallback: number, +) => { + const stored = localStorage.getItem(item); + if (!stored) { + return fallback; + } + return Number(stored); +}; + +// filter difficulty +const filterDifficultyInput = document.querySelector('#filterDifficulty') as HTMLInputElement; +const filterDifficultyDisplay = document.querySelector('[data-display="filter_difficulty"]') as HTMLElement; +filterDifficultyInput.addEventListener('input', (e) => { + localStorage.setItem('filter_difficulty', filterDifficultyInput.value); + filterDifficulty = filterDifficultyInput.valueAsNumber; + filterDifficultyDisplay.textContent = filterDifficultyInput.value; + rerenderFeed && rerenderFeed(); +}); +filterDifficulty = getNumberFromStorage('filter_difficulty', 0); +filterDifficultyInput.valueAsNumber = filterDifficulty; +filterDifficultyDisplay.textContent = filterDifficultyInput.value; + +// mining difficulty target +const miningTargetInput = document.querySelector('#miningTarget') as HTMLInputElement; +miningTargetInput.addEventListener('input', (e) => { + localStorage.setItem('mining_target', miningTargetInput.value); + difficulty = miningTargetInput.valueAsNumber; +}); +// arbitrary difficulty default, still experimenting. +difficulty = getNumberFromStorage('mining_target', 16); +miningTargetInput.valueAsNumber = difficulty; + +// mining timeout +const miningTimeoutInput = document.querySelector('#miningTimeout') as HTMLInputElement; +miningTimeoutInput.addEventListener('input', (e) => { + localStorage.setItem('mining_timeout', miningTimeoutInput.value); + timeout = miningTimeoutInput.valueAsNumber; +}); +timeout = getNumberFromStorage('mining_timeout', 5); +miningTimeoutInput.valueAsNumber = timeout; -- 2.46.2 From efda7737c876a3cf1de0e96499eb41344c49146d Mon Sep 17 00:00:00 2001 From: OFF0 Date: Fri, 17 Mar 2023 10:35:04 +0100 Subject: [PATCH 18/34] utils: move updateelemheight to dom utils --- src/main.js | 13 +------------ src/utils/dom.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/main.js b/src/main.js index ebdb7ee..01a0b11 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,6 @@ import {generatePrivateKey, getPublicKey, nip19, signEvent} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; -import {elem, elemCanvas, elemShrink, parseTextContent} from './utils/dom'; +import {elem, elemCanvas, elemShrink, parseTextContent, updateElemHeight} from './utils/dom'; import {bounce, dateTime, formatTime} from './utils/time'; import {getHost, getNoxyUrl, isWssUrl} from './utils/url'; import {powEvent} from './system'; @@ -561,17 +561,6 @@ writeInput.addEventListener('input', () => { }); writeInput.addEventListener('blur', () => sendStatus.textContent = ''); -function updateElemHeight(el) { - el.style.removeProperty('height'); - if (el.value) { - el.style.paddingBottom = 0; - el.style.paddingTop = 0; - el.style.height = el.scrollHeight + 'px'; - el.style.removeProperty('padding-bottom'); - el.style.removeProperty('padding-top'); - } -} - diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 81e9d7f..5088a5b 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -162,3 +162,17 @@ export const elemShrink = (el: HTMLElement) => { shrink.addEventListener('animationend', () => shrink.remove(), {once: true}); return shrink; }; + + +export const updateElemHeight = ( + el: HTMLInputElement | HTMLTextAreaElement +) => { + el.style.removeProperty('height'); + if (el.value) { + el.style.paddingBottom = '0'; + el.style.paddingTop = '0'; + el.style.height = el.scrollHeight + 'px'; + el.style.removeProperty('padding-bottom'); + el.style.removeProperty('padding-top'); + } +}; -- 2.46.2 From 43754149a9262c998714bf49760d82c7341de201 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Fri, 17 Mar 2023 10:36:38 +0100 Subject: [PATCH 19/34] settings: move remaining settings code with this change everything related to user settings is now in settings.ts module. --- src/main.js | 137 ++------------------------------------------ src/settings.ts | 149 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 134 deletions(-) diff --git a/src/main.js b/src/main.js index 01a0b11..d7551da 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,4 @@ -import {generatePrivateKey, getPublicKey, nip19, signEvent} from 'nostr-tools'; +import {nip19, signEvent} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; import {elem, elemCanvas, elemShrink, parseTextContent, updateElemHeight} from './utils/dom'; import {bounce, dateTime, formatTime} from './utils/time'; @@ -8,7 +8,7 @@ import {sub24hFeed, subNote, subProfile} from './subscriptions' import {publish} from './relays'; import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; -import {config} from './settings'; +import {closeSettingsView, config, toggleSettingsView} from './settings'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ function onEvent(evt, relay) { @@ -608,7 +608,6 @@ window.addEventListener('popstate', (event) => { route(location.pathname); }); -const settingsView = document.querySelector('#settings'); const publishView = document.querySelector('#newNote'); document.body.addEventListener('click', (e) => { @@ -618,9 +617,7 @@ document.body.addEventListener('click', (e) => { if (a) { if ('nav' in a.dataset) { e.preventDefault(); - if (!settingsView.hidden) { - settingsView.hidden = true; - } + closeSettingsView(); if (!publishView.hidden) { publishView.hidden = true; } @@ -646,7 +643,7 @@ document.body.addEventListener('click', (e) => { upvote(id, pubkey); break; case 'settings': - settingsView.hidden = !settingsView.hidden; + toggleSettingsView(); break; case 'new-note': if (publishView.hidden) { @@ -682,129 +679,3 @@ document.body.addEventListener('click', (e) => { // hideNewMessage(true); // } // }); - -// settings -const settingsForm = document.querySelector('form[name="settings"]'); -const privateKeyInput = settingsForm.querySelector('#privatekey'); -const pubKeyInput = settingsForm.querySelector('#pubkey'); -const statusMessage = settingsForm.querySelector('#keystatus'); -const generateBtn = settingsForm.querySelector('button[name="generate"]'); -const importBtn = settingsForm.querySelector('button[name="import"]'); -const privateTgl = settingsForm.querySelector('button[name="privatekey-toggle"]'); - -generateBtn.addEventListener('click', () => { - const privatekey = generatePrivateKey(); - const pubkey = getPublicKey(privatekey); - if (validKeys(privatekey, pubkey)) { - privateKeyInput.value = privatekey; - pubKeyInput.value = pubkey; - statusMessage.textContent = 'private-key created!'; - statusMessage.hidden = false; - } -}); - -importBtn.addEventListener('click', () => { - const privatekey = privateKeyInput.value; - const pubkeyInput = pubKeyInput.value; - if (validKeys(privatekey, pubkeyInput)) { - localStorage.setItem('private_key', privatekey); - localStorage.setItem('pub_key', pubkeyInput); - statusMessage.textContent = 'stored private and public key locally!'; - statusMessage.hidden = false; - config.pubkey = pubkeyInput; - } -}); - -settingsForm.addEventListener('input', () => validKeys(privateKeyInput.value, pubKeyInput.value)); -privateKeyInput.addEventListener('paste', (event) => { - if (pubKeyInput.value || !event.clipboardData) { - return; - } - if (privateKeyInput.value === '' || ( // either privatekey field is empty - privateKeyInput.selectionStart === 0 // or the whole text is selected and replaced with the clipboard - && privateKeyInput.selectionEnd === privateKeyInput.value.length - )) { // only generate the pubkey if no data other than the text from clipboard will be used - try { - pubKeyInput.value = getPublicKey(event.clipboardData.getData('text')); - } catch(err) {} // settings form will call validKeys on input and display the error - } -}); - -function validKeys(privatekey, pubkey) { - try { - if (getPublicKey(privatekey) === pubkey) { - statusMessage.hidden = true; - statusMessage.textContent = 'public-key corresponds to private-key'; - importBtn.removeAttribute('disabled'); - return true; - } else { - statusMessage.textContent = 'private-key does not correspond to public-key!' - } - } catch (e) { - statusMessage.textContent = `not a valid private-key: ${e.message || e}`; - } - statusMessage.hidden = false; - importBtn.setAttribute('disabled', true); - return false; -} - -privateTgl.addEventListener('click', () => { - privateKeyInput.type = privateKeyInput.type === 'text' ? 'password' : 'text'; -}); - -privateKeyInput.value = localStorage.getItem('private_key'); -pubKeyInput.value = localStorage.getItem('pub_key'); - -// profile -const profileForm = document.querySelector('form[name="profile"]'); -const profileSubmit = profileForm.querySelector('button[type="submit"]'); -const profileStatus = document.querySelector('#profilestatus'); -const onProfileError = err => { - profileStatus.hidden = false; - profileStatus.textContent = err.message -}; -profileForm.addEventListener('input', (e) => { - if (e.target.nodeName === 'TEXTAREA') { - updateElemHeight(e.target); - } - const form = new FormData(profileForm); - const name = form.get('name'); - const about = form.get('about'); - const picture = form.get('picture'); - profileSubmit.disabled = !(name || about || picture); -}); - -profileForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const form = new FormData(profileForm); - const newProfile = await powEvent({ - kind: 0, - pubkey: config.pubkey, - content: JSON.stringify(Object.fromEntries(form)), - tags: [], - created_at: Math.floor(Date.now() * 0.001), - }, { - difficulty: config.difficulty, - statusElem: profileStatus, - timeout: config.timeout, - }).catch(console.warn); - if (!newProfile) { - profileStatus.textContent = 'publishing profile data canceled'; - profileStatus.hidden = false; - return; - } - const privatekey = localStorage.getItem('private_key'); - const sig = signEvent(newProfile, privatekey); - // TODO: validateEvent - if (sig) { - publish({...newProfile, sig}, (relay, error) => { - if (error) { - return console.error(error, relay); - } - console.info(`publish request sent to ${relay}`); - profileStatus.textContent = 'profile metadata successfully published'; - profileStatus.hidden = false; - profileSubmit.disabled = true; - }); - } -}); diff --git a/src/settings.ts b/src/settings.ts index 407bdba..589e092 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,13 @@ -import {generatePrivateKey, getPublicKey} from 'nostr-tools'; +import {generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; +import {updateElemHeight} from './utils/dom'; +import {powEvent} from './system'; +import {publish} from './relays'; + +const settingsView = document.querySelector('#settings') as HTMLElement; + +export const closeSettingsView = () => settingsView.hidden = true; + +export const toggleSettingsView = () => settingsView.hidden = !settingsView.hidden; let pubkey: string = ''; @@ -90,3 +99,141 @@ miningTimeoutInput.addEventListener('input', (e) => { }); timeout = getNumberFromStorage('mining_timeout', 5); miningTimeoutInput.valueAsNumber = timeout; + + +// settings +const settingsForm = document.querySelector('form[name="settings"]') as HTMLFormElement; +const privateKeyInput = settingsForm.querySelector('#privatekey') as HTMLInputElement; +const pubKeyInput = settingsForm.querySelector('#pubkey') as HTMLInputElement; +const statusMessage = settingsForm.querySelector('#keystatus') as HTMLElement; +const generateBtn = settingsForm.querySelector('button[name="generate"]') as HTMLButtonElement; +const importBtn = settingsForm.querySelector('button[name="import"]') as HTMLButtonElement; +const privateTgl = settingsForm.querySelector('button[name="privatekey-toggle"]') as HTMLButtonElement; + +const validKeys = ( + privatekey: string, + pubkey: string, +) => { + try { + if (getPublicKey(privatekey) === pubkey) { + statusMessage.hidden = true; + statusMessage.textContent = 'public-key corresponds to private-key'; + importBtn.removeAttribute('disabled'); + return true; + } else { + statusMessage.textContent = 'private-key does not correspond to public-key!' + } + } catch (e) { + statusMessage.textContent = `not a valid private-key: ${e.message || e}`; + } + statusMessage.hidden = false; + importBtn.disabled = true; + return false; +}; + +generateBtn.addEventListener('click', () => { + const privatekey = generatePrivateKey(); + const pubkey = getPublicKey(privatekey); + if (validKeys(privatekey, pubkey)) { + privateKeyInput.value = privatekey; + pubKeyInput.value = pubkey; + statusMessage.textContent = 'private-key created!'; + statusMessage.hidden = false; + } +}); + +importBtn.addEventListener('click', () => { + const privatekey = privateKeyInput.value; + const pubkeyInput = pubKeyInput.value; + if (validKeys(privatekey, pubkeyInput)) { + localStorage.setItem('private_key', privatekey); + localStorage.setItem('pub_key', pubkeyInput); + statusMessage.textContent = 'stored private and public key locally!'; + statusMessage.hidden = false; + config.pubkey = pubkeyInput; + } +}); + +settingsForm.addEventListener('input', () => validKeys(privateKeyInput.value, pubKeyInput.value)); + +privateKeyInput.addEventListener('paste', (event) => { + if (pubKeyInput.value || !event.clipboardData) { + return; + } + if (privateKeyInput.value === '' || ( // either privatekey field is empty + privateKeyInput.selectionStart === 0 // or the whole text is selected and replaced with the clipboard + && privateKeyInput.selectionEnd === privateKeyInput.value.length + )) { // only generate the pubkey if no data other than the text from clipboard will be used + try { + pubKeyInput.value = getPublicKey(event.clipboardData.getData('text')); + } catch(err) {} // settings form will call validKeys on input and display the error + } +}); + +privateTgl.addEventListener('click', () => { + privateKeyInput.type = privateKeyInput.type === 'text' ? 'password' : 'text'; +}); + +privateKeyInput.value = localStorage.getItem('private_key') || ''; +pubKeyInput.value = localStorage.getItem('pub_key') || ''; + +// profile +const profileForm = document.querySelector('form[name="profile"]') as HTMLFormElement; +const profileSubmit = profileForm.querySelector('button[type="submit"]') as HTMLButtonElement; +const profileStatus = document.querySelector('#profilestatus') as HTMLElement; +// const onProfileError = err => { +// profileStatus.hidden = false; +// profileStatus.textContent = err.message +// }; +profileForm.addEventListener('input', (e) => { + if (e.target instanceof HTMLElement) { + if (e.target?.nodeName === 'TEXTAREA') { + updateElemHeight(e.target as HTMLTextAreaElement); + } + } + const form = new FormData(profileForm); + const name = form.get('name'); + const about = form.get('about'); + const picture = form.get('picture'); + profileSubmit.disabled = !(name || about || picture); +}); + +profileForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const form = new FormData(profileForm); + const newProfile = await powEvent({ + kind: 0, + pubkey: config.pubkey, + content: JSON.stringify(Object.fromEntries(form)), + tags: [], + created_at: Math.floor(Date.now() * 0.001) + }, { + difficulty: config.difficulty, + statusElem: profileStatus, + timeout: config.timeout, + }).catch(console.warn); + if (!newProfile) { + profileStatus.textContent = 'publishing profile data canceled'; + profileStatus.hidden = false; + return; + } + const privatekey = localStorage.getItem('private_key'); + if (!privatekey) { + profileStatus.textContent = 'no private key to sign'; + profileStatus.hidden = false; + return; + } + const sig = signEvent(newProfile, privatekey); + // TODO: validateEvent + if (sig) { + publish({...newProfile, sig}, (relay, error) => { + if (error) { + return console.error(error, relay); + } + console.info(`publish request sent to ${relay}`); + profileStatus.textContent = 'profile metadata successfully published'; + profileStatus.hidden = false; + profileSubmit.disabled = true; + }); + } +}); -- 2.46.2 From 52e2a314212c184bd81a59972657d5c7896f2ce6 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Fri, 17 Mar 2023 12:56:05 +0100 Subject: [PATCH 20/34] reactions: move reaction logic to typed module --- src/main.js | 93 ++++------------------------------------- src/reactions.ts | 105 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 85 deletions(-) create mode 100644 src/reactions.ts diff --git a/src/main.js b/src/main.js index d7551da..8c6d2c7 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,4 @@ -import {nip19, signEvent} from 'nostr-tools'; +import {nip19} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; import {elem, elemCanvas, elemShrink, parseTextContent, updateElemHeight} from './utils/dom'; import {bounce, dateTime, formatTime} from './utils/time'; @@ -9,6 +9,7 @@ import {publish} from './relays'; import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; import {closeSettingsView, config, toggleSettingsView} from './settings'; +import {getReactions, getReactionContents, handleReaction, handleUpvote} from './reactions'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ function onEvent(evt, relay) { @@ -117,42 +118,6 @@ function renderReply(evt, relay) { setViewElem(evt.id, reply); } -const reactionMap = {}; - -const getReactionList = (id) => { - return reactionMap[id]?.map(({content}) => content) || []; -}; - -function handleReaction(evt, relay) { - // last id is the note that is being reacted to https://github.com/nostr-protocol/nips/blob/master/25.md - const lastEventTag = evt.tags.filter(hasEventTag).at(-1); - if (!lastEventTag || !evt.content.length) { - // ignore reactions with no content - return; - } - const [, eventId] = lastEventTag; - if (reactionMap[eventId]) { - if (reactionMap[eventId].find(reaction => reaction.id === evt.id)) { - // already received this reaction from a different relay - return; - } - reactionMap[eventId] = [evt, ...(reactionMap[eventId])]; - } else { - reactionMap[eventId] = [evt]; - } - const article = getViewElem(eventId); - if (article) { - const button = article.querySelector('button[name="star"]'); - const reactions = button.querySelector('[data-reactions]'); - reactions.textContent = reactionMap[eventId].length; - if (evt.pubkey === config.pubkey) { - const star = button.querySelector('img[src*="star"]'); - star?.setAttribute('src', '/assets/star-fill.svg'); - star?.setAttribute('title', getReactionList(eventId).join(' ')); - } - } -} - const restoredReplyTo = localStorage.getItem('reply_to'); config.rerenderFeed = () => { @@ -232,8 +197,8 @@ function createTextNote(evt, relay) { const replies = replyList.filter(({replyTo}) => replyTo === evt.id); // const isLongContent = evt.content.trimRight().length > 280; // const content = isLongContent ? evt.content.slice(0, 280) : evt.content; - const hasReactions = reactionMap[evt.id]?.length > 0; - const didReact = hasReactions && !!reactionMap[evt.id].find(reaction => reaction.pubkey === config.pubkey); + const reactions = getReactions(evt.id); + const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey); const replyFeed = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : []; const [content, {firstLink}] = parseTextContent(evt.content); const body = elem('div', {className: 'mbox-body'}, [ @@ -260,9 +225,9 @@ function createTextNote(evt, relay) { alt: didReact ? '✭' : '✩', // ♥ height: 24, width: 24, src: `/assets/${didReact ? 'star-fill' : 'star'}.svg`, - title: getReactionList(evt.id).join(' '), + title: getReactionContents(evt.id).join(' '), }), - elem('small', {data: {reactions: ''}}, hasReactions ? reactionMap[evt.id].length : ''), + elem('small', {data: {reactions: ''}}, reactions.length || ''), ]), ]), ]); @@ -456,49 +421,6 @@ function appendReplyForm(el) { requestAnimationFrame(() => writeInput.focus()); } -async function upvote(eventId, eventPubkey) { - const note = replyList.find(r => r.id === eventId) || textNoteList.find(n => n.id === (eventId)); - const tags = [ - ...note.tags - .filter(tag => ['e', 'p'].includes(tag[0])) // take e and p tags from event - .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 = getViewElem(eventId); - const reactionBtn = article.querySelector('[name="star"]'); - const statusElem = article.querySelector('[data-reactions]'); - reactionBtn.disabled = true; - const newReaction = await powEvent({ - kind: 7, - pubkey: config.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: config.difficulty, - statusElem, - timeout: config.timeout, - }).catch(console.warn); - if (!newReaction) { - statusElem.textContent = reactionMap[eventId]?.length; - reactionBtn.disabled = false; - return; - } - const privatekey = localStorage.getItem('private_key'); - const sig = signEvent(newReaction, privatekey); - // TODO: validateEvent - if (sig) { - statusElem.textContent = 'publishing…'; - publish({...newReaction, sig}, (relay, error) => { - if (error) { - return console.error(error, relay); - } - console.info(`event published by ${relay}`); - }); - reactionBtn.disabled = false; - } -} - // send const sendStatus = document.querySelector('#sendstatus'); const onSendError = err => sendStatus.textContent = err.message; @@ -640,7 +562,8 @@ document.body.addEventListener('click', (e) => { localStorage.setItem('reply_to', id); break; case 'star': - upvote(id, pubkey); + const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); + handleUpvote(note); break; case 'settings': toggleSettingsView(); diff --git a/src/reactions.ts b/src/reactions.ts new file mode 100644 index 0000000..b63d846 --- /dev/null +++ b/src/reactions.ts @@ -0,0 +1,105 @@ +import {Event, signEvent, UnsignedEvent} from 'nostr-tools'; +import {powEvent} from './system'; +import {publish} from './relays'; +import {hasEventTag} from './events'; +import {getViewElem} from './view'; +import {config} from './settings'; + +type ReactionMap = { + [eventId: string]: Array +}; + +const reactionMap: ReactionMap = {}; + +export const getReactions = (eventId: string) => reactionMap[eventId] || []; + +export const getReactionContents = (eventId: string) => { + return reactionMap[eventId]?.map(({content}) => content) || []; +}; + +export const handleReaction = ( + evt: Event, + relay: string, +) => { + // last id is the note that is being reacted to https://github.com/nostr-protocol/nips/blob/master/25.md + const lastEventTag = evt.tags.filter(hasEventTag).at(-1); + if (!lastEventTag || !evt.content.length) { + // ignore reactions with no content + return; + } + const [, eventId] = lastEventTag; + if (reactionMap[eventId]) { + if (reactionMap[eventId].find(reaction => reaction.id === evt.id)) { + // already received this reaction from a different relay + return; + } + reactionMap[eventId] = [evt, ...(reactionMap[eventId])]; + } else { + reactionMap[eventId] = [evt]; + } + const article = getViewElem(eventId); + if (article) { + const button = article.querySelector('button[name="star"]') as HTMLButtonElement; + const reactions = button.querySelector('[data-reactions]') as HTMLElement; + reactions.textContent = `${reactionMap[eventId].length || ''}`; + if (evt.pubkey === config.pubkey) { + const star = button.querySelector('img[src*="star"]'); + star?.setAttribute('src', '/assets/star-fill.svg'); + star?.setAttribute('title', getReactionContents(eventId).join(' ')); + } + } +}; + +const upvote = async ( + eventId: string, + evt: UnsignedEvent, +) => { + const article = getViewElem(eventId); + const reactionBtn = article.querySelector('button[name="star"]') as HTMLButtonElement; + const statusElem = article.querySelector('[data-reactions]') as HTMLElement; + reactionBtn.disabled = true; + const newReaction = await powEvent(evt, { + difficulty: config.difficulty, + statusElem, + timeout: config.timeout, + }).catch(console.warn); + if (!newReaction) { + statusElem.textContent = `${getReactions(eventId)?.length}`; + reactionBtn.disabled = false; + return; + } + const privatekey = localStorage.getItem('private_key'); + if (!privatekey) { + statusElem.textContent = 'no private key to sign'; + statusElem.hidden = false; + return; + } + const sig = signEvent(newReaction, privatekey); + // TODO: validateEvent + if (sig) { + statusElem.textContent = 'publishing…'; + publish({...newReaction, sig}, (relay, error) => { + if (error) { + return console.error(error, relay); + } + console.info(`event published by ${relay}`); + }); + reactionBtn.disabled = false; + } +}; + +export const handleUpvote = (evt: Event) => { + const tags = [ + ...evt.tags + .filter(tag => ['e', 'p'].includes(tag[0])) // take e and p tags from event + .map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields, TODO: use relay? + ['e', evt.id], ['p', evt.pubkey], // last e and p tag is the id and pubkey of the note being reacted to (nip-25) + ]; + upvote(evt.id, { + kind: 7, + pubkey: config.pubkey, + content: '+', + tags, + created_at: Math.floor(Date.now() * 0.001), + }); +}; -- 2.46.2 From 9a34d4f31e679c5a24c3bc015f92635d449024c5 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Fri, 17 Mar 2023 12:57:49 +0100 Subject: [PATCH 21/34] refactor: global click listener and cleanup breaking up the global click callback to make it easier in the future to move some parts into ui modules such as settings.ts. --- src/main.js | 127 ++++++++++++++++++++++++---------------------------- 1 file changed, 59 insertions(+), 68 deletions(-) diff --git a/src/main.js b/src/main.js index 8c6d2c7..4e6dc56 100644 --- a/src/main.js +++ b/src/main.js @@ -483,22 +483,6 @@ writeInput.addEventListener('input', () => { }); writeInput.addEventListener('blur', () => sendStatus.textContent = ''); - - - - - - - - - - - - -document.body.onload = () => console.log('------------ pageload ------------') - - - // subscribe and change view function route(path) { if (path === '/') { @@ -532,62 +516,57 @@ window.addEventListener('popstate', (event) => { const publishView = document.querySelector('#newNote'); -document.body.addEventListener('click', (e) => { - const a = e.target.closest('a'); - const pubkey = e.target.closest('[data-pubkey]')?.dataset.pubkey; - const id = e.target.closest('[data-id]')?.dataset.id; - if (a) { - if ('nav' in a.dataset) { - e.preventDefault(); - closeSettingsView(); - if (!publishView.hidden) { - publishView.hidden = true; - } - const href = a.getAttribute('href'); - route(href); - history.pushState({}, null, href); - e.preventDefault(); +const handleLink = (e, a) => { + if ('nav' in a.dataset) { + e.preventDefault(); + closeSettingsView(); + if (!publishView.hidden) { + publishView.hidden = true; } - return; + const href = a.getAttribute('href'); + route(href); + history.pushState({}, null, href); + e.preventDefault(); } - const button = e.target.closest('button'); - if (button) { - switch(button.name) { - case 'reply': - if (localStorage.getItem('reply_to') === id) { - writeInput.blur(); - return; - } - appendReplyForm(button.closest('.buttons')); - localStorage.setItem('reply_to', id); - break; - case 'star': - const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); - handleUpvote(note); - break; - case 'settings': - toggleSettingsView(); - break; - case 'new-note': - if (publishView.hidden) { - localStorage.removeItem('reply_to'); // should it forget old replyto context? - publishView.append(writeForm); - if (writeInput.value.trimRight()) { - writeInput.style.removeProperty('height'); - } - requestAnimationFrame(() => { - updateElemHeight(writeInput); - writeInput.focus(); - }); - publishView.removeAttribute('hidden'); - } else { - publishView.hidden = true; +}; + +const handleButton = (e, button) => { + const id = e.target.closest('[data-id]')?.dataset.id; + switch(button.name) { + case 'reply': + if (localStorage.getItem('reply_to') === id) { + writeInput.blur(); + return; + } + appendReplyForm(button.closest('.buttons')); + localStorage.setItem('reply_to', id); + break; + case 'star': + const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); + handleUpvote(note); + break; + case 'settings': + toggleSettingsView(); + break; + case 'new-note': + if (publishView.hidden) { + localStorage.removeItem('reply_to'); // should it forget old replyto context? + publishView.append(writeForm); + if (writeInput.value.trimRight()) { + writeInput.style.removeProperty('height'); } - break; - case 'back': + requestAnimationFrame(() => { + updateElemHeight(writeInput); + writeInput.focus(); + }); + publishView.removeAttribute('hidden'); + } else { publishView.hidden = true; - break; - } + } + break; + case 'back': + publishView.hidden = true; + break; } // const container = e.target.closest('[data-append]'); // if (container) { @@ -595,6 +574,18 @@ document.body.addEventListener('click', (e) => { // delete container.dataset.append; // return; // } +}; + +document.body.addEventListener('click', (e) => { + const a = e.target.closest('a'); + if (a) { + handleLink(e, a); + return; + } + const button = e.target.closest('button'); + if (button) { + handleButton(e, button); + } }); // document.body.addEventListener('keyup', (e) => { -- 2.46.2 From 976ea21d529cd9c49c6100d028c8b78f17a5a557 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 18 Mar 2023 10:12:28 +0100 Subject: [PATCH 22/34] write: move reply and write-new-text note to write.ts --- src/main.js | 170 ++++++++------------------------------------------- src/write.ts | 154 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 146 deletions(-) create mode 100644 src/write.ts diff --git a/src/main.js b/src/main.js index 4e6dc56..d2259bd 100644 --- a/src/main.js +++ b/src/main.js @@ -1,15 +1,15 @@ import {nip19} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; -import {elem, elemCanvas, elemShrink, parseTextContent, updateElemHeight} from './utils/dom'; +import {elem, elemCanvas, parseTextContent} from './utils/dom'; import {bounce, dateTime, formatTime} from './utils/time'; import {getHost, getNoxyUrl, isWssUrl} from './utils/url'; -import {powEvent} from './system'; import {sub24hFeed, subNote, subProfile} from './subscriptions' -import {publish} from './relays'; import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; import {closeSettingsView, config, toggleSettingsView} from './settings'; import {getReactions, getReactionContents, handleReaction, handleUpvote} from './reactions'; +import {closePublishView, openWriteInput, togglePublishView} from './write'; + // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ function onEvent(evt, relay) { @@ -118,8 +118,6 @@ function renderReply(evt, relay) { setViewElem(evt.id, reply); } -const restoredReplyTo = localStorage.getItem('reply_to'); - config.rerenderFeed = () => { clearView(); renderFeed(); @@ -190,8 +188,6 @@ function linkPreview(href, id, relay) { }); } -const writeInput = document.querySelector('textarea[name="message"]'); - function createTextNote(evt, relay) { const {host, img, name, time, userName} = getMetadata(evt, relay); const replies = replyList.filter(({replyTo}) => replyTo === evt.id); @@ -201,6 +197,20 @@ function createTextNote(evt, relay) { const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey); const replyFeed = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : []; const [content, {firstLink}] = parseTextContent(evt.content); + const buttons = 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: getReactionContents(evt.id).join(' '), + }), + elem('small', {data: {reactions: ''}}, reactions.length || ''), + ]), + ]); const body = elem('div', {className: 'mbox-body'}, [ elem('header', { className: 'mbox-header', @@ -216,24 +226,10 @@ function createTextNote(evt, relay) { ...content, (firstLink && validatePow(evt)) ? linkPreview(firstLink, 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: getReactionContents(evt.id).join(' '), - }), - elem('small', {data: {reactions: ''}}, reactions.length || ''), - ]), - ]), + buttons, ]); - if (restoredReplyTo === evt.id) { - appendReplyForm(body.querySelector('.buttons')); - requestAnimationFrame(() => updateElemHeight(writeInput)); + if (localStorage.getItem('reply_to') === evt.id) { + openWriteInput(buttons); } return renderArticle([ elem('div', {className: 'mbox-img'}, [img]), body, @@ -393,96 +389,6 @@ function getMetadata(evt, relay) { return {host, img, name, time, userName}; } -const writeForm = document.querySelector('#writeForm'); - -writeInput.addEventListener('focusout', () => { - const reply_to = localStorage.getItem('reply_to'); - if (reply_to && writeInput.value === '') { - writeInput.addEventListener('transitionend', (event) => { - if (!reply_to || reply_to === localStorage.getItem('reply_to') && !writeInput.style.height) { // should prob use some class or data-attr instead of relying on height - writeForm.after(elemShrink(writeInput)); - writeForm.remove(); - localStorage.removeItem('reply_to'); - } - }, {once: true}); - } -}); - -function appendReplyForm(el) { - writeForm.before(elemShrink(writeInput)); - writeInput.blur(); - writeInput.style.removeProperty('height'); - el.after(writeForm); - if (writeInput.value && !writeInput.value.trimRight()) { - writeInput.value = ''; - } else { - requestAnimationFrame(() => updateElemHeight(writeInput)); - } - requestAnimationFrame(() => writeInput.focus()); -} - -// send -const sendStatus = document.querySelector('#sendstatus'); -const onSendError = err => sendStatus.textContent = err.message; -const publishBtn = document.querySelector('#publish'); -writeForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const privatekey = localStorage.getItem('private_key'); - if (!config.pubkey || !privatekey) { - return onSendError(new Error('no pubkey/privatekey')); - } - const content = writeInput.value.trimRight(); - if (!content) { - return onSendError(new Error('message is empty')); - } - const replyTo = localStorage.getItem('reply_to'); - const close = () => { - sendStatus.textContent = ''; - writeInput.value = ''; - writeInput.style.removeProperty('height'); - publishBtn.disabled = true; - if (replyTo) { - localStorage.removeItem('reply_to'); - publishView.append(writeForm); - } - publishView.hidden = true; - }; - const tags = replyTo ? [['e', replyTo]] : []; // , eventRelayMap[replyTo][0] - const newEvent = await powEvent({ - kind: 1, - content, - pubkey: config.pubkey, - tags, - created_at: Math.floor(Date.now() * 0.001), - }, { - difficulty: config.difficulty, - statusElem: sendStatus, - timeout: config.timeout, - }).catch(console.warn); - if (!newEvent) { - close(); - return; - } - const sig = signEvent(newEvent, privatekey); - // TODO validateEvent - if (sig) { - sendStatus.textContent = 'publishing…'; - publish({...newEvent, sig}, (relay, error) => { - if (error) { - return console.log(error, relay); - } - console.info(`publish request sent to ${relay}`); - close(); - }); - } -}); - -writeInput.addEventListener('input', () => { - publishBtn.disabled = !writeInput.value.trimRight(); - updateElemHeight(writeInput); -}); -writeInput.addEventListener('blur', () => sendStatus.textContent = ''); - // subscribe and change view function route(path) { if (path === '/') { @@ -514,15 +420,11 @@ window.addEventListener('popstate', (event) => { route(location.pathname); }); -const publishView = document.querySelector('#newNote'); - const handleLink = (e, a) => { if ('nav' in a.dataset) { e.preventDefault(); closeSettingsView(); - if (!publishView.hidden) { - publishView.hidden = true; - } + closePublishView(); const href = a.getAttribute('href'); route(href); history.pushState({}, null, href); @@ -534,12 +436,7 @@ const handleButton = (e, button) => { const id = e.target.closest('[data-id]')?.dataset.id; switch(button.name) { case 'reply': - if (localStorage.getItem('reply_to') === id) { - writeInput.blur(); - return; - } - appendReplyForm(button.closest('.buttons')); - localStorage.setItem('reply_to', id); + openWriteInput(button, id); break; case 'star': const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); @@ -549,23 +446,10 @@ const handleButton = (e, button) => { toggleSettingsView(); break; case 'new-note': - if (publishView.hidden) { - localStorage.removeItem('reply_to'); // should it forget old replyto context? - publishView.append(writeForm); - if (writeInput.value.trimRight()) { - writeInput.style.removeProperty('height'); - } - requestAnimationFrame(() => { - updateElemHeight(writeInput); - writeInput.focus(); - }); - publishView.removeAttribute('hidden'); - } else { - publishView.hidden = true; - } + togglePublishView(); break; case 'back': - publishView.hidden = true; + closePublishView(); break; } // const container = e.target.closest('[data-append]'); @@ -587,9 +471,3 @@ document.body.addEventListener('click', (e) => { handleButton(e, button); } }); - -// document.body.addEventListener('keyup', (e) => { -// if (e.key === 'Escape') { -// hideNewMessage(true); -// } -// }); diff --git a/src/write.ts b/src/write.ts new file mode 100644 index 0000000..33594af --- /dev/null +++ b/src/write.ts @@ -0,0 +1,154 @@ +import {signEvent} from 'nostr-tools'; +import {elemShrink, updateElemHeight} from './utils/dom'; +import {powEvent} from './system'; +import {config} from './settings'; +import {publish} from './relays'; + +// form used to write and publish textnotes for replies and new notes +const writeForm = document.querySelector('#writeForm') as HTMLFormElement; +const writeInput = document.querySelector('textarea[name="message"]') as HTMLTextAreaElement; + +// overlay for writing new text notes +const publishView = document.querySelector('#newNote') as HTMLElement; + +const openWriteView = () => { + publishView.append(writeForm); + if (writeInput.value.trimRight()) { + writeInput.style.removeProperty('height'); + } + requestAnimationFrame(() => { + updateElemHeight(writeInput); + writeInput.focus(); + }); + publishView.removeAttribute('hidden'); +}; + +export const closePublishView = () => publishView.hidden = true; + +export const togglePublishView = () => { + if (publishView.hidden) { + localStorage.removeItem('reply_to'); // should it forget old replyto context? + openWriteView(); + } else { + publishView.hidden = true; + } +}; + +const closeWriteInput = () => writeInput.blur(); + +export const openWriteInput = ( + button: HTMLElement, + id: string, +) => { + appendReplyForm(button.closest('.buttons') as HTMLElement); + localStorage.setItem('reply_to', id); +}; + +export const toggleWriteInput = ( + button: HTMLElement, + id: string, +) => { + if (id && localStorage.getItem('reply_to') === id) { + closeWriteInput(); + return; + } + appendReplyForm(button.closest('.buttons') as HTMLElement); + localStorage.setItem('reply_to', id); +}; + +// const updateWriteInputHeight = () => updateElemHeight(writeInput); + +writeInput.addEventListener('focusout', () => { + const reply_to = localStorage.getItem('reply_to'); + if (reply_to && writeInput.value === '') { + writeInput.addEventListener('transitionend', (event) => { + if (!reply_to || reply_to === localStorage.getItem('reply_to') && !writeInput.style.height) { // should prob use some class or data-attr instead of relying on height + writeForm.after(elemShrink(writeInput)); + writeForm.remove(); + localStorage.removeItem('reply_to'); + } + }, {once: true}); + } +}); + +// document.body.addEventListener('keyup', (e) => { +// if (e.key === 'Escape') { +// hideNewMessage(true); +// } +// }); + +const sendStatus = document.querySelector('#sendstatus') as HTMLElement; +const publishBtn = document.querySelector('#publish') as HTMLButtonElement; +const onSendError = (err: Error) => sendStatus.textContent = err.message; + +writeForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const privatekey = localStorage.getItem('private_key'); + if (!config.pubkey || !privatekey) { + return onSendError(new Error('no pubkey/privatekey')); + } + const content = writeInput.value.trimRight(); + if (!content) { + return onSendError(new Error('message is empty')); + } + const replyTo = localStorage.getItem('reply_to'); + const close = () => { + sendStatus.textContent = ''; + writeInput.value = ''; + writeInput.style.removeProperty('height'); + publishBtn.disabled = true; + if (replyTo) { + localStorage.removeItem('reply_to'); + publishView.append(writeForm); + } + publishView.hidden = true; + }; + const tags = replyTo ? [['e', replyTo]] : []; // , eventRelayMap[replyTo][0] + const newEvent = await powEvent({ + kind: 1, + content, + pubkey: config.pubkey, + tags, + created_at: Math.floor(Date.now() * 0.001), + }, { + difficulty: config.difficulty, + statusElem: sendStatus, + timeout: config.timeout, + }).catch(console.warn); + if (!newEvent) { + close(); + return; + } + const sig = signEvent(newEvent, privatekey); + // TODO validateEvent + if (sig) { + sendStatus.textContent = 'publishing…'; + publish({...newEvent, sig}, (relay, error) => { + if (error) { + return console.log(error, relay); + } + console.info(`publish request sent to ${relay}`); + close(); + }); + } +}); + +writeInput.addEventListener('input', () => { + publishBtn.disabled = !writeInput.value.trimRight(); + updateElemHeight(writeInput); +}); + +writeInput.addEventListener('blur', () => sendStatus.textContent = ''); + +function appendReplyForm(el: HTMLElement) { + writeForm.before(elemShrink(writeInput)); + writeInput.blur(); + writeInput.style.removeProperty('height'); + el.after(writeForm); + if (writeInput.value && !writeInput.value.trimRight()) { + writeInput.value = ''; + } else { + requestAnimationFrame(() => updateElemHeight(writeInput)); + } + requestAnimationFrame(() => writeInput.focus()); +} -- 2.46.2 From 7abd6fdc6e5034d3d8dbe92d3d06f2ecd8370960 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 18 Mar 2023 10:44:27 +0100 Subject: [PATCH 23/34] nav: change from data-nav to simple href check --- src/index.html | 2 +- src/main.js | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/index.html b/src/index.html index 8567e2b..fd3e039 100644 --- a/src/index.html +++ b/src/index.html @@ -102,7 +102,7 @@ diff --git a/src/main.js b/src/main.js index d2259bd..ec8aa15 100644 --- a/src/main.js +++ b/src/main.js @@ -218,9 +218,9 @@ function createTextNote(evt, relay) { ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} ${evt.content}` }, [ - elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`, data: {nav: true}}, name || userName), + elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`}, name || userName), ' ', - elem('a', {href: `/${evt.nip19.note}`, data: {nav: true}}, formatTime(time)), + elem('a', {href: `/${evt.nip19.note}`}, elem('time', {dateTime: time.toISOString()}, formatTime(time))), ]), elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ ...content, @@ -421,11 +421,14 @@ window.addEventListener('popstate', (event) => { }); const handleLink = (e, a) => { - if ('nav' in a.dataset) { - e.preventDefault(); + const href = a.getAttribute('href'); + if ( + href === '/' + || href.startsWith('/note') + || href.startsWith('/npub') + ) { closeSettingsView(); closePublishView(); - const href = a.getAttribute('href'); route(href); history.pushState({}, null, href); e.preventDefault(); -- 2.46.2 From 78588ec1c7cb517fb5ae1f583e059327360f7fdf Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 19 Mar 2023 22:36:13 +0100 Subject: [PATCH 24/34] media: move noxy preview link fetch logic to media.ts --- src/main.js | 68 +----------------------------- src/media.ts | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 67 deletions(-) create mode 100644 src/media.ts diff --git a/src/main.js b/src/main.js index ec8aa15..9897fc3 100644 --- a/src/main.js +++ b/src/main.js @@ -9,6 +9,7 @@ import {clearView, getViewContent, getViewElem, setViewElem, view} from './view' import {closeSettingsView, config, toggleSettingsView} from './settings'; import {getReactions, getReactionContents, handleReaction, handleUpvote} from './reactions'; import {closePublishView, openWriteInput, togglePublishView} from './write'; +import {linkPreview, parseContent} from './media'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ @@ -129,64 +130,6 @@ setInterval(() => { }); }, 10000); -const fetchQue = []; -let fetchPending; -const fetchNext = (href, id, relay) => { - const noxy = getNoxyUrl('meta', href, id, relay); - const previewId = noxy.searchParams.toString(); - if (fetchPending) { - fetchQue.push({href, id, relay}); - return previewId; - } - fetchPending = fetch(noxy.href) - .then(data => { - if (data.status === 200) { - return data.json(); - } - // fetchQue.push({href, id, relay}); // could try one more time - return Promise.reject(data); - }) - .then(meta => { - const container = document.getElementById(previewId); - const content = []; - if (meta.images[0]) { - content.push(elem('img', {className: 'preview-image', loading: 'lazy', src: getNoxyUrl('data', meta.images[0], id, relay).href})); - } - if (meta.title) { - content.push(elem('h2', {className: 'preview-title'}, meta.title)); - } - if (meta.descr) { - content.push(elem('p', {className: 'preview-descr'}, meta.descr)) - } - if (content.length) { - container.append(elem('a', {href, rel: 'noopener noreferrer', target: '_blank'}, content)); - container.classList.add('preview-loaded'); - } - }) - .finally(() => { - fetchPending = false; - if (fetchQue.length) { - const {href, id, relay} = fetchQue.shift(); - return fetchNext(href, id, relay); - } - }) - .catch(err => err.text && err.text()) - .then(errMsg => errMsg && console.warn(errMsg)); - return previewId; -}; - -function linkPreview(href, id, relay) { - if ((/\.(gif|jpe?g|png)$/i).test(href)) { - return elem('div', {}, - [elem('img', {className: 'preview-image-only', loading: 'lazy', src: getNoxyUrl('data', href, id, relay).href})] - ); - } - const previewId = fetchNext(href, id, relay); - return elem('div', { - className: 'preview', - id: previewId - }); -} function createTextNote(evt, relay) { const {host, img, name, time, userName} = getMetadata(evt, relay); @@ -313,15 +256,6 @@ function renderArticle(content, props = {}) { const userList = []; // const tempContactList = {}; -function parseContent(content) { - try { - return JSON.parse(content); - } catch(err) { - console.log(evt); - console.error(err); - } -} - function handleMetadata(evt, relay) { const content = parseContent(evt.content); if (content) { diff --git a/src/media.ts b/src/media.ts new file mode 100644 index 0000000..5fa28c4 --- /dev/null +++ b/src/media.ts @@ -0,0 +1,114 @@ +import { elem } from './utils/dom'; +import { getNoxyUrl } from './utils/url'; + +export const parseContent = (content: string) => { + try { + return JSON.parse(content); + } catch(err) { + console.warn(err, content); + return null; + } +} + +type FetchItem = { + href: string; + id: string; + relay: string; +}; + +type NoxyData = { + title: string; + descr: string; + images: string[]; +}; + +const fetchQue: Array = []; + +let fetchPending: (null | Promise) = null; + +const fetchNext = ( + href: string, + id: string, + relay: string, +) => { + const noxy = getNoxyUrl('meta', href, id, relay); + if (!noxy) { + return false; + } + const previewId = noxy.searchParams.toString(); + if (fetchPending) { + fetchQue.push({href, id, relay}); + return previewId; + } + fetchPending = fetch(noxy.href) + .then(data => { + if (data.status === 200) { + return data.json(); + } + // fetchQue.push({href, id, relay}); // could try one more time + return Promise.reject(data); + }) + .then(meta => { + const container = document.getElementById(previewId); + const content: Array = []; + if (meta.images[0]) { + const img = getNoxyUrl('data', meta.images[0], id, relay); + img && content.push( + elem('img', { + className: 'preview-image', + loading: 'lazy', + src: img.href, + }) + ); + } + if (meta.title) { + content.push(elem('h2', {className: 'preview-title'}, meta.title)); + } + if (meta.descr) { + content.push(elem('p', {className: 'preview-descr'}, meta.descr)) + } + if (container && content.length) { + container.append(elem('a', {href, rel: 'noopener noreferrer', target: '_blank'}, content)); + container.classList.add('preview-loaded'); + } + }) + .finally(() => { + fetchPending = null; + if (fetchQue.length) { + const {href, id, relay} = fetchQue.shift() as FetchItem; + return fetchNext(href, id, relay); + } + }) + .catch(err => err.text && err.text()) + .then(errMsg => errMsg && console.warn(errMsg)); + + return previewId; +}; + +export const linkPreview = ( + href: string, + id: string, + relay: string, +) => { + if ((/\.(gif|jpe?g|png)$/i).test(href)) { + const img = getNoxyUrl('data', href, id, relay); + if (!img) { + return null; + } + return elem('div', {}, + [elem('img', { + className: 'preview-image-only', + loading: 'lazy', + src: img.href, + })] + ); + } + const previewId = fetchNext(href, id, relay); + if (!previewId) { + return null; + } + return elem('div', { + className: 'preview', + id: previewId, + }); +}; -- 2.46.2 From ab1ea2fa417fef830b3a1d7d95ab28acca726d1a Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 19 Mar 2023 23:02:16 +0100 Subject: [PATCH 25/34] view: fix hidden settings view css regression, somehow flex order doesnt overlay on the main views, fixed with z-index. --- src/styles/view.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/view.css b/src/styles/view.css index beefa1f..c1d4524 100644 --- a/src/styles/view.css +++ b/src/styles/view.css @@ -20,7 +20,7 @@ main { } aside { - order: 2; + z-index: 2; } nav { -- 2.46.2 From e1ba0b4c6fb9c3abb46c49e62b19fc8780c69f8e Mon Sep 17 00:00:00 2001 From: OFF0 Date: Fri, 24 Mar 2023 11:32:50 +0100 Subject: [PATCH 26/34] profile: move and type profile metadata --- src/main.js | 114 ++-------------------------------- src/media.ts | 2 +- src/profiles.ts | 158 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 111 deletions(-) create mode 100644 src/profiles.ts diff --git a/src/main.js b/src/main.js index 9897fc3..d0a6fcb 100644 --- a/src/main.js +++ b/src/main.js @@ -1,15 +1,16 @@ import {nip19} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; -import {elem, elemCanvas, parseTextContent} from './utils/dom'; +import {elem, parseTextContent} from './utils/dom'; import {bounce, dateTime, formatTime} from './utils/time'; -import {getHost, getNoxyUrl, isWssUrl} from './utils/url'; +import {isWssUrl} from './utils/url'; import {sub24hFeed, subNote, subProfile} from './subscriptions' import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; import {closeSettingsView, config, toggleSettingsView} from './settings'; import {getReactions, getReactionContents, handleReaction, handleUpvote} from './reactions'; import {closePublishView, openWriteInput, togglePublishView} from './write'; -import {linkPreview, parseContent} from './media'; +import {linkPreview} from './media'; +import {getMetadata, handleMetadata} from './profiles'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ @@ -196,43 +197,6 @@ function handleRecommendServer(evt, relay) { setViewElem(evt.id, art); } -function handleContactList(evt, relay) { - if (getViewElem(evt.id)) { - return; - } - const art = renderUpdateContact(evt, relay); - if (textNoteList.length < 2) { - getViewContent().append(art); - return; - } - const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); - getViewElem(closestTextNotes[0].id).after(art); - setViewElem(evt.id, art); - // const user = userList.find(u => u.pupkey === evt.pubkey); - // if (user) { - // console.log(`TODO: add contact list for ${evt.pubkey.slice(0, 8)} on ${relay}`, evt.tags); - // } else { - // tempContactList[relay] = tempContactList[relay] - // ? [...tempContactList[relay], evt] - // : [evt]; - // } -} - -function renderUpdateContact(evt, relay) { - const {img, time, userName} = getMetadata(evt, relay); - const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ - elem('header', {className: 'mbox-header'}, [ - elem('small', {}, []), - ]), - elem('pre', {title: JSON.stringify(evt.content)}, [ - elem('strong', {}, userName), - ' updated contacts: ', - JSON.stringify(evt.tags), - ]), - ]); - return renderArticle([img, body], {className: 'mbox-updated-contact', data: {id: evt.id, pubkey: evt.pubkey, relay}}); -} - function renderRecommendServer(evt, relay) { const {img, name, time, userName} = getMetadata(evt, relay); const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ @@ -253,76 +217,6 @@ function renderArticle(content, props = {}) { return elem('article', {...props, className}, content); } -const userList = []; -// const tempContactList = {}; - -function handleMetadata(evt, relay) { - const content = parseContent(evt.content); - if (content) { - setMetadata(evt, relay, content); - } -} - -function setMetadata(evt, relay, content) { - let user = userList.find(u => u.pubkey === evt.pubkey); - const picture = getNoxyUrl('data', content.picture, evt.id, relay).href; - if (!user) { - user = { - metadata: {[relay]: content}, - ...(content.picture && {picture}), - pubkey: evt.pubkey, - }; - userList.push(user); - } else { - user.metadata[relay] = { - ...user.metadata[relay], - timestamp: evt.created_at, - ...content, - }; - // use only the first profile pic (for now), different pics on each releay are not supported yet - if (!user.picture) { - user.picture = picture; - } - } - // update profile images - if (user.picture && validatePow(evt)) { - document.body - .querySelectorAll(`canvas[data-pubkey="${evt.pubkey}"]`) - .forEach(canvas => (canvas.parentNode.replaceChild(elem('img', {src: user.picture}), canvas))); - } - if (user.metadata[relay].name) { - document.body - .querySelectorAll(`[data-id="${evt.pubkey}"] .mbox-username:not(.mbox-kind0-name)`) - .forEach(username => { - username.textContent = user.metadata[relay].name; - username.classList.add('mbox-kind0-name'); - }); - } - // if (tempContactList[relay]) { - // const updates = tempContactList[relay].filter(update => update.pubkey === evt.pubkey); - // if (updates) { - // console.log('TODO: add contact list (kind 3)', updates); - // } - // } -} - -function getMetadata(evt, relay) { - const host = getHost(relay); - const user = userList.find(user => user.pubkey === evt.pubkey); - const userImg = user?.picture; - const name = user?.metadata[relay]?.name; - const userName = name || evt.pubkey.slice(0, 8); - const userAbout = user?.metadata[relay]?.about || ''; - const img = (userImg && validatePow(evt)) ? elem('img', { - alt: `${userName} ${host}`, - loading: 'lazy', - src: userImg, - title: `${userName} on ${host} ${userAbout}`, - }) : elemCanvas(evt.pubkey); - const time = new Date(evt.created_at * 1000); - return {host, img, name, time, userName}; -} - // subscribe and change view function route(path) { if (path === '/') { diff --git a/src/media.ts b/src/media.ts index 5fa28c4..5653aa6 100644 --- a/src/media.ts +++ b/src/media.ts @@ -1,7 +1,7 @@ import { elem } from './utils/dom'; import { getNoxyUrl } from './utils/url'; -export const parseContent = (content: string) => { +export const parseContent = (content: string): unknown => { try { return JSON.parse(content); } catch(err) { diff --git a/src/profiles.ts b/src/profiles.ts new file mode 100644 index 0000000..ccbe0d9 --- /dev/null +++ b/src/profiles.ts @@ -0,0 +1,158 @@ +import {Event} from 'nostr-tools'; +import {elem, elemCanvas} from './utils/dom'; +import {getHost, getNoxyUrl} from './utils/url'; +import {validatePow} from './events'; +import {parseContent} from './media'; + +type Metadata = { + name?: string; + about?: string; + picture?: string; +}; + +type Profile = { + metadata: { + [relay: string]: Metadata; + }; + name?: string; + picture?: string; + pubkey: string; +}; + +const userList: Array = []; +// const tempContactList = {}; + +const setMetadata = ( + evt: Event, + relay: string, + metadata: Metadata, +) => { + let user = userList.find(u => u.pubkey === evt.pubkey); + if (!user) { + user = { + metadata: {[relay]: metadata}, + pubkey: evt.pubkey, + }; + userList.push(user); + } else { + user.metadata[relay] = { + ...user.metadata[relay], + // timestamp: evt.created_at, + ...metadata, + }; + } + + // store the first seen name (for now) as main user.name + if (!user.name && metadata.name) { + user.name = metadata.name; + } + + // use the first seen profile pic (for now), pics from different relays are not supported yet + if (!user.picture && metadata.picture) { + const imgUrl = getNoxyUrl('data', metadata.picture, evt.id, relay); + if (imgUrl) { + user.picture = imgUrl.href; + + // update profile images that used some nip-13 work + if (imgUrl.href && validatePow(evt)) { + document.body + .querySelectorAll(`canvas[data-pubkey="${evt.pubkey}"]`) + .forEach(canvas => canvas.parentNode?.replaceChild(elem('img', {src: imgUrl.href}), canvas)); + } + } + } + + // update profile names + const name = user.metadata[relay].name || user.name || ''; + if (name) { + document.body + .querySelectorAll(`[data-pubkey="${evt.pubkey}"] .mbox-username:not(.mbox-kind0-name)`) + .forEach((username: HTMLElement) => { + username.textContent = name; + username.classList.add('mbox-kind0-name'); + }); + } + // if (tempContactList[relay]) { + // const updates = tempContactList[relay].filter(update => update.pubkey === evt.pubkey); + // if (updates) { + // console.log('TODO: add contact list (kind 3)', updates); + // } + // } +}; + +export const handleMetadata = (evt: Event, relay: string) => { + const content = parseContent(evt.content); + if (!content || typeof content !== 'object' || Array.isArray(content)) { + console.warn('expected nip-01 JSON object with user info, but got something funny', evt); + return; + } + const hasNameString = 'name' in content && typeof content.name === 'string'; + const hasAboutString = 'about' in content && typeof content.about === 'string'; + const hasPictureString = 'picture' in content && typeof content.picture === 'string'; + // custom + const hasDisplayName = 'display_name' in content && typeof content.display_name === 'string'; + if (!hasNameString && !hasAboutString && !hasPictureString && !hasDisplayName) { + console.warn('expected basic nip-01 user info (name, about, picture) but nothing found', evt); + return; + } + const metadata: Metadata = { + ...(hasNameString && {name: content.name as string} || hasDisplayName && {name: content.display_name as string}), + ...(hasAboutString && {about: content.about as string}), + ...(hasPictureString && {picture: content.picture as string}), + }; + setMetadata(evt, relay, metadata); +}; + +export const getMetadata = (evt: Event, relay: string) => { + const host = getHost(relay); + const user = userList.find(user => user.pubkey === evt.pubkey); + const userImg = user?.picture; + const name = user?.metadata[relay]?.name || user?.name; + const userName = name || evt.pubkey.slice(0, 8); + const userAbout = user?.metadata[relay]?.about || ''; + const img = (userImg && validatePow(evt)) ? elem('img', { + alt: `${userName} ${host}`, + loading: 'lazy', + src: userImg, + title: `${userName} on ${host} ${userAbout}`, + }) : elemCanvas(evt.pubkey); + const time = new Date(evt.created_at * 1000); + return {host, img, name, time, userName}; +}; + +/* export function handleContactList(evt, relay) { + if (getViewElem(evt.id)) { + return; + } + const art = renderUpdateContact(evt, relay); + if (textNoteList.length < 2) { + getViewContent().append(art); + return; + } + const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); + getViewElem(closestTextNotes[0].id).after(art); + setViewElem(evt.id, art); + // const user = userList.find(u => u.pupkey === evt.pubkey); + // if (user) { + // console.log(`TODO: add contact list for ${evt.pubkey.slice(0, 8)} on ${relay}`, evt.tags); + // } else { + // tempContactList[relay] = tempContactList[relay] + // ? [...tempContactList[relay], evt] + // : [evt]; + // } +} */ + +// function renderUpdateContact(evt, relay) { +// const {img, time, userName} = getMetadata(evt, relay); +// const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ +// elem('header', {className: 'mbox-header'}, [ +// elem('small', {}, []), +// ]), +// elem('pre', {title: JSON.stringify(evt.content)}, [ +// elem('strong', {}, userName), +// ' updated contacts: ', +// JSON.stringify(evt.tags), +// ]), +// ]); +// return renderArticle([img, body], {className: 'mbox-updated-contact', data: {id: evt.id, pubkey: evt.pubkey, relay}}); +// } -- 2.46.2 From f9fe892937e254d783192c904cc6b6d1543ba03f Mon Sep 17 00:00:00 2001 From: OFF0 Date: Fri, 24 Mar 2023 18:27:18 +0100 Subject: [PATCH 27/34] main: convert remaining main to typescript --- esbuildconf.js | 2 +- src/{main.js => main.ts} | 127 +++++++++++++++++++++++++-------------- src/utils/array.ts | 8 +++ src/utils/dom.ts | 19 +++++- 4 files changed, 106 insertions(+), 50 deletions(-) rename src/{main.js => main.ts} (72%) create mode 100644 src/utils/array.ts diff --git a/esbuildconf.js b/esbuildconf.js index f2a783c..1a4095a 100644 --- a/esbuildconf.js +++ b/esbuildconf.js @@ -18,7 +18,7 @@ export const options = { 'src/favicon.ico', 'src/index.html', 'src/styles/main.css', - 'src/main.js', + 'src/main.ts', 'src/manifest.json', 'src/worker.js', ], diff --git a/src/main.js b/src/main.ts similarity index 72% rename from src/main.js rename to src/main.ts index d0a6fcb..d921ee5 100644 --- a/src/main.js +++ b/src/main.ts @@ -1,6 +1,6 @@ -import {nip19} from 'nostr-tools'; +import {Event, nip19} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; -import {elem, parseTextContent} from './utils/dom'; +import {elem, elemArticle, parseTextContent} from './utils/dom'; import {bounce, dateTime, formatTime} from './utils/time'; import {isWssUrl} from './utils/url'; import {sub24hFeed, subNote, subProfile} from './subscriptions' @@ -14,7 +14,7 @@ import {getMetadata, handleMetadata} from './profiles'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ -function onEvent(evt, relay) { +function onEvent(evt: Event, relay: string) { switch (evt.kind) { case 0: handleMetadata(evt, relay); @@ -35,14 +35,28 @@ function onEvent(evt, relay) { } } -const textNoteList = []; // could use indexDB -const eventRelayMap = {}; // eventId: [relay1, relay2] +type EventWithNip19 = Event & { + nip19: { + note: string; + npub: string; + } +}; +const textNoteList: Array = []; // could use indexDB -const renderNote = (evt, i, sortedFeeds) => { +type EventRelayMap = { + [eventId: string]: string[]; +}; +const eventRelayMap: EventRelayMap = {}; // eventId: [relay1, relay2] + +const renderNote = ( + evt: EventWithNip19, + i: number, + sortedFeeds: EventWithNip19[], +) => { if (getViewElem(evt.id)) { // note already in view return; } - const article = createTextNote(evt, eventRelayMap[evt.id]); + const article = createTextNote(evt, eventRelayMap[evt.id][0]); if (i === 0) { getViewContent().append(article); } else { @@ -51,8 +65,11 @@ const renderNote = (evt, i, sortedFeeds) => { setViewElem(evt.id, article); }; -const hasEnoughPOW = ([tag, , commitment], eventId) => { - return tag === 'nonce' && commitment >= config.filterDifficulty && zeroLeadingBitsCount(eventId) >= config.filterDifficulty; +const hasEnoughPOW = ( + [tag, , commitment]: string[], + eventId: string +) => { + return tag === 'nonce' && Number(commitment) >= config.filterDifficulty && zeroLeadingBitsCount(eventId) >= config.filterDifficulty; }; const renderFeed = bounce(() => { @@ -67,9 +84,9 @@ const renderFeed = bounce(() => { .forEach(renderNote); }, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s) -function handleTextNote(evt, relay) { +function handleTextNote(evt: Event, relay: string) { if (eventRelayMap[evt.id]) { - eventRelayMap[evt.id] = [relay, ...(eventRelayMap[evt.id])]; + eventRelayMap[evt.id] = [...(eventRelayMap[evt.id]), relay]; // TODO: just push? } else { eventRelayMap[evt.id] = [relay]; const evtWithNip19 = { @@ -90,9 +107,13 @@ function handleTextNote(evt, relay) { } } -const replyList = []; +type EventWithNip19AndReplyTo = EventWithNip19 & { + replyTo: string; +} + +const replyList: Array = []; -function handleReply(evt, relay) { +function handleReply(evt: EventWithNip19, relay: string) { if ( getViewElem(evt.id) // already rendered probably received from another relay || evt.tags.some(isMention) // ignore mentions for now @@ -100,12 +121,16 @@ function handleReply(evt, relay) { return; } const replyTo = getReplyTo(evt); + if (!replyTo) { + console.warn('expected to find reply-to-event-id', evt); + return; + } const evtWithReplyTo = {replyTo, ...evt}; replyList.push(evtWithReplyTo); renderReply(evtWithReplyTo, relay); } -function renderReply(evt, relay) { +function renderReply(evt: EventWithNip19AndReplyTo, relay: string) { const parent = getViewElem(evt.replyTo); if (!parent) { // root article has not been rendered return; @@ -126,20 +151,20 @@ config.rerenderFeed = () => { }; setInterval(() => { - document.querySelectorAll('time[datetime]').forEach(timeElem => { + document.querySelectorAll('time[datetime]').forEach((timeElem: HTMLTimeElement) => { timeElem.textContent = formatTime(new Date(timeElem.dateTime)); }); }, 10000); -function createTextNote(evt, relay) { +function createTextNote(evt: EventWithNip19, relay: string) { const {host, img, name, time, userName} = getMetadata(evt, relay); const replies = replyList.filter(({replyTo}) => replyTo === evt.id); // const isLongContent = evt.content.trimRight().length > 280; // const content = isLongContent ? evt.content.slice(0, 280) : evt.content; const reactions = getReactions(evt.id); const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey); - const replyFeed = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : []; + const replyFeed: Array = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : []; const [content, {firstLink}] = parseTextContent(evt.content); const buttons = elem('div', {className: 'buttons'}, [ elem('button', {name: 'reply', type: 'button'}, [ @@ -168,20 +193,21 @@ function createTextNote(evt, relay) { ]), elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ ...content, - (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : '', + (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : null, ]), buttons, ]); if (localStorage.getItem('reply_to') === evt.id) { - openWriteInput(buttons); + openWriteInput(buttons, evt.id); } - return renderArticle([ - elem('div', {className: 'mbox-img'}, [img]), body, - replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '', + return elemArticle([ + elem('div', {className: 'mbox-img'}, img), + body, + ...(replies[0] ? [elem('div', {className: 'mobx-replies'}, replyFeed.reverse())] : []), ], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); } -function handleRecommendServer(evt, relay) { +function handleRecommendServer(evt: Event, relay: string) { if (getViewElem(evt.id) || !isWssUrl(evt.content)) { return; } @@ -190,14 +216,15 @@ function handleRecommendServer(evt, relay) { getViewContent().append(art); } else { const closestTextNotes = textNoteList - .filter(note => !config.filterDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && commitment >= config.filterDifficulty)) // TODO: prob change to hasEnoughPOW + // TODO: prob change to hasEnoughPOW + .filter(note => !config.filterDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && Number(commitment) >= config.filterDifficulty)) .sort(sortEventCreatedAt(evt.created_at)); getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed } setViewElem(evt.id, art); } -function renderRecommendServer(evt, relay) { +function renderRecommendServer(evt: Event, relay: string) { const {img, name, time, userName} = getMetadata(evt, relay); const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ elem('header', {className: 'mbox-header'}, [ @@ -207,23 +234,22 @@ function renderRecommendServer(evt, relay) { ]), ` recommends server: ${evt.content}`, ]); - return renderArticle([ + return elemArticle([ elem('div', {className: 'mbox-img'}, [img]), body ], {className: 'mbox-recommend-server', data: {id: evt.id, pubkey: evt.pubkey}}); } -function renderArticle(content, props = {}) { - const className = props.className ? ['mbox', props?.className].join(' ') : 'mbox'; - return elem('article', {...props, className}, content); -} - // subscribe and change view -function route(path) { +function route(path: string) { if (path === '/') { sub24hFeed(onEvent); view('/'); } else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) { const {type, data} = nip19.decode(path.slice(1)); + if (typeof data !== 'string') { + console.warn('nip19 ProfilePointer, EventPointer and AddressPointer are not yet supported'); + return; + } switch(type) { case 'note': subNote(data, onEvent); @@ -248,8 +274,12 @@ window.addEventListener('popstate', (event) => { route(location.pathname); }); -const handleLink = (e, a) => { +const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => { const href = a.getAttribute('href'); + if (typeof href !== 'string') { + console.warn('expected anchor to have href attribute', a); + return; + } if ( href === '/' || href.startsWith('/note') @@ -258,20 +288,23 @@ const handleLink = (e, a) => { closeSettingsView(); closePublishView(); route(href); - history.pushState({}, null, href); + history.pushState({}, '', href); e.preventDefault(); } }; -const handleButton = (e, button) => { - const id = e.target.closest('[data-id]')?.dataset.id; +const handleButton = (button: HTMLButtonElement) => { + const id = (button.closest('[data-id]') as HTMLElement)?.dataset.id; + if (!id) { + return; + } switch(button.name) { case 'reply': openWriteInput(button, id); break; case 'star': const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); - handleUpvote(note); + note && handleUpvote(note); break; case 'settings': toggleSettingsView(); @@ -291,14 +324,16 @@ const handleButton = (e, button) => { // } }; -document.body.addEventListener('click', (e) => { - const a = e.target.closest('a'); - if (a) { - handleLink(e, a); - return; - } - const button = e.target.closest('button'); - if (button) { - handleButton(e, button); +document.body.addEventListener('click', (event: MouseEvent) => { + if (event.target instanceof HTMLElement) { + const a = event.target?.closest('a'); + if (a) { + handleLink(a, event); + return; + } + const button = event.target.closest('button'); + if (button) { + handleButton(button); + } } }); diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 0000000..db2a0d3 --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,8 @@ +/** + * type-guarded function that tells TypeScript (in strictNullChecks mode) that you're filtering out null/undefined items. + * example: array.filter(isNotNull) + */ +export const isNotNull = (item: T): item is NonNullable => item != null; + +// alternative +// const const isNotNull = (item: T | null): item is T => item !== null; diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 5088a5b..e550577 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -1,13 +1,18 @@ +import {isNotNull} from './array'; import {isValidURL} from './url'; type DataAttributes = { data: { [key: string]: string | number; - } + }, +} & { + dataset: never, // the dataset property itself is readonly }; type Attributes = Partial; +type Children = Array | HTMLElement | string | number | null; + /** * example usage: * @@ -23,7 +28,7 @@ type Attributes = Partial; export const elem = ( name: Extract, attrs?: Attributes, - children?: Array | string | number, + children?: Children, ): HTMLElementTagNameMap[Name] => { const el = document.createElement(name); if (attrs) { @@ -37,7 +42,7 @@ export const elem = ( } if (children != null) { if (Array.isArray(children)) { - el.append(...children); + el.append(...children.filter(isNotNull)); } else { switch (typeof children) { case 'number': @@ -176,3 +181,11 @@ export const updateElemHeight = ( el.style.removeProperty('padding-top'); } }; + +export const elemArticle = ( + content: Array, + attrs: Attributes = {} +) => { + const className = attrs.className ? ['mbox', attrs?.className].join(' ') : 'mbox'; + return elem('article', {...attrs, className}, content); +}; -- 2.46.2 From bbfa4ae54508c0d8b4fa18c99dffd794277161cf Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 8 Apr 2023 11:52:49 +0200 Subject: [PATCH 28/34] main: fix global click handler settings view and write new message didnt show. reason was typescript expected an instance of an HTMLElement but this didnt allow for SVG elements inside the write button. Another reason was that the condition expected a parent with data-id which isn't the case for settings button nor write-new-message. --- src/main.ts | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/main.ts b/src/main.ts index d921ee5..0d6fa7e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -294,27 +294,28 @@ const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => { }; const handleButton = (button: HTMLButtonElement) => { - const id = (button.closest('[data-id]') as HTMLElement)?.dataset.id; - if (!id) { - return; - } switch(button.name) { - case 'reply': - openWriteInput(button, id); - break; - case 'star': - const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); - note && handleUpvote(note); - break; case 'settings': toggleSettingsView(); - break; + return; case 'new-note': togglePublishView(); - break; + return; case 'back': closePublishView(); - break; + return; + } + const id = (button.closest('[data-id]') as HTMLElement)?.dataset.id; + if (id) { + switch(button.name) { + case 'reply': + openWriteInput(button, id); + break; + case 'star': + const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); + note && handleUpvote(note); + break; + } } // const container = e.target.closest('[data-append]'); // if (container) { @@ -325,15 +326,14 @@ const handleButton = (button: HTMLButtonElement) => { }; document.body.addEventListener('click', (event: MouseEvent) => { - if (event.target instanceof HTMLElement) { - const a = event.target?.closest('a'); - if (a) { - handleLink(a, event); - return; - } - const button = event.target.closest('button'); - if (button) { - handleButton(button); - } + const target = event.target as HTMLElement; + const a = target?.closest('a'); + if (a) { + handleLink(a, event); + return; + } + const button = target?.closest('button'); + if (button) { + handleButton(button); } }); -- 2.46.2 From aec72b6c62e4dedc14e2ab8c5091b85f48836202 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 9 Apr 2023 09:02:56 +0200 Subject: [PATCH 29/34] refactor: function to es6 arrow functions so that it doesnt depend on function hoisting. --- src/main.ts | 251 +++++++++++++++++++++---------------------- src/styles/cards.css | 5 - src/worker.js | 6 +- src/write.ts | 26 ++--- 4 files changed, 141 insertions(+), 147 deletions(-) diff --git a/src/main.ts b/src/main.ts index 0d6fa7e..7d88cf4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,27 +14,6 @@ import {getMetadata, handleMetadata} from './profiles'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ -function onEvent(evt: Event, relay: string) { - switch (evt.kind) { - case 0: - handleMetadata(evt, relay); - break; - case 1: - handleTextNote(evt, relay); - break; - case 2: - handleRecommendServer(evt, relay); - break; - case 3: - // handleContactList(evt, relay); - break; - case 7: - handleReaction(evt, relay); - default: - // console.log(`TODO: add support for event kind ${evt.kind}`/*, evt*/) - } -} - type EventWithNip19 = Event & { nip19: { note: string; @@ -48,6 +27,62 @@ type EventRelayMap = { }; const eventRelayMap: EventRelayMap = {}; // eventId: [relay1, relay2] +type EventWithNip19AndReplyTo = EventWithNip19 & { + replyTo: string; +}; + +const replyList: Array = []; + +const createTextNote = (evt: EventWithNip19, relay: string) => { + const {host, img, name, time, userName} = getMetadata(evt, relay); + const replies = replyList.filter(({replyTo}) => replyTo === evt.id); + // const isLongContent = evt.content.trimRight().length > 280; + // const content = isLongContent ? evt.content.slice(0, 280) : evt.content; + const reactions = getReactions(evt.id); + const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey); + const replyFeed: Array = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : []; + const [content, {firstLink}] = parseTextContent(evt.content); + const buttons = 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: getReactionContents(evt.id).join(' '), + }), + elem('small', {data: {reactions: ''}}, reactions.length || ''), + ]), + ]); + const body = elem('div', {className: 'mbox-body'}, [ + elem('header', { + className: 'mbox-header', + title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${host}\n\nEvent-id: ${evt.id} + ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} + ${evt.content}` + }, [ + elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`}, name || userName), + ' ', + elem('a', {href: `/${evt.nip19.note}`}, elem('time', {dateTime: time.toISOString()}, formatTime(time))), + ]), + elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ + ...content, + (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : null, + ]), + buttons, + ]); + if (localStorage.getItem('reply_to') === evt.id) { + openWriteInput(buttons, evt.id); + } + return elemArticle([ + elem('div', {className: 'mbox-img'}, img), + body, + ...(replies[0] ? [elem('div', {className: 'mobx-replies'}, replyFeed.reverse())] : []), + ], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); +}; + const renderNote = ( evt: EventWithNip19, i: number, @@ -84,36 +119,22 @@ const renderFeed = bounce(() => { .forEach(renderNote); }, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s) -function handleTextNote(evt: Event, relay: string) { - if (eventRelayMap[evt.id]) { - eventRelayMap[evt.id] = [...(eventRelayMap[evt.id]), relay]; // TODO: just push? - } else { - eventRelayMap[evt.id] = [relay]; - const evtWithNip19 = { - nip19: { - note: nip19.noteEncode(evt.id), - npub: nip19.npubEncode(evt.pubkey), - }, - ...evt - }; - if (evt.tags.some(hasEventTag)) { - handleReply(evtWithNip19, relay); - } else { - textNoteList.push(evtWithNip19); - } +const renderReply = (evt: EventWithNip19AndReplyTo, relay: string) => { + const parent = getViewElem(evt.replyTo); + if (!parent) { // root article has not been rendered + return; } - if (!getViewElem(evt.id)) { - renderFeed(); + let replyContainer = parent.querySelector('.mobx-replies'); + if (!replyContainer) { + replyContainer = elem('div', {className: 'mobx-replies'}); + parent.append(replyContainer); } -} - -type EventWithNip19AndReplyTo = EventWithNip19 & { - replyTo: string; -} - -const replyList: Array = []; + const reply = createTextNote(evt, relay); + replyContainer.append(reply); + setViewElem(evt.id, reply); +}; -function handleReply(evt: EventWithNip19, relay: string) { +const handleReply = (evt: EventWithNip19, relay: string) => { if ( getViewElem(evt.id) // already rendered probably received from another relay || evt.tags.some(isMention) // ignore mentions for now @@ -128,22 +149,30 @@ function handleReply(evt: EventWithNip19, relay: string) { const evtWithReplyTo = {replyTo, ...evt}; replyList.push(evtWithReplyTo); renderReply(evtWithReplyTo, relay); -} +}; -function renderReply(evt: EventWithNip19AndReplyTo, relay: string) { - const parent = getViewElem(evt.replyTo); - if (!parent) { // root article has not been rendered - return; +const handleTextNote = (evt: Event, relay: string) => { + if (eventRelayMap[evt.id]) { + eventRelayMap[evt.id] = [...(eventRelayMap[evt.id]), relay]; // TODO: just push? + } else { + eventRelayMap[evt.id] = [relay]; + const evtWithNip19 = { + nip19: { + note: nip19.noteEncode(evt.id), + npub: nip19.npubEncode(evt.pubkey), + }, + ...evt, + }; + if (evt.tags.some(hasEventTag)) { + handleReply(evtWithNip19, relay); + } else { + textNoteList.push(evtWithNip19); + } } - let replyContainer = parent.querySelector('.mobx-replies'); - if (!replyContainer) { - replyContainer = elem('div', {className: 'mobx-replies'}); - parent.append(replyContainer); + if (!getViewElem(evt.id)) { + renderFeed(); } - const reply = createTextNote(evt, relay); - replyContainer.append(reply); - setViewElem(evt.id, reply); -} +}; config.rerenderFeed = () => { clearView(); @@ -156,58 +185,22 @@ setInterval(() => { }); }, 10000); - -function createTextNote(evt: EventWithNip19, relay: string) { - const {host, img, name, time, userName} = getMetadata(evt, relay); - const replies = replyList.filter(({replyTo}) => replyTo === evt.id); - // const isLongContent = evt.content.trimRight().length > 280; - // const content = isLongContent ? evt.content.slice(0, 280) : evt.content; - const reactions = getReactions(evt.id); - const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey); - const replyFeed: Array = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : []; - const [content, {firstLink}] = parseTextContent(evt.content); - const buttons = 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: getReactionContents(evt.id).join(' '), - }), - elem('small', {data: {reactions: ''}}, reactions.length || ''), - ]), - ]); - const body = elem('div', {className: 'mbox-body'}, [ - elem('header', { - className: 'mbox-header', - title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${host}\n\nEvent-id: ${evt.id} - ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} - ${evt.content}` - }, [ - elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`}, name || userName), - ' ', - elem('a', {href: `/${evt.nip19.note}`}, elem('time', {dateTime: time.toISOString()}, formatTime(time))), - ]), - elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ - ...content, - (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : null, +const renderRecommendServer = (evt: Event, relay: string) => { + const {img, name, time, userName} = getMetadata(evt, relay); + const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ + elem('header', {className: 'mbox-header'}, [ + elem('small', {}, [ + elem('strong', {}, userName) + ]), ]), - buttons, + ` recommends server: ${evt.content}`, ]); - if (localStorage.getItem('reply_to') === evt.id) { - openWriteInput(buttons, evt.id); - } return elemArticle([ - elem('div', {className: 'mbox-img'}, img), - body, - ...(replies[0] ? [elem('div', {className: 'mobx-replies'}, replyFeed.reverse())] : []), - ], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); -} + elem('div', {className: 'mbox-img'}, [img]), body + ], {className: 'mbox-recommend-server', data: {id: evt.id, pubkey: evt.pubkey}}); +}; -function handleRecommendServer(evt: Event, relay: string) { +const handleRecommendServer = (evt: Event, relay: string) => { if (getViewElem(evt.id) || !isWssUrl(evt.content)) { return; } @@ -222,25 +215,31 @@ function handleRecommendServer(evt: Event, relay: string) { getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed } setViewElem(evt.id, art); -} +}; -function renderRecommendServer(evt: Event, relay: string) { - const {img, name, time, userName} = getMetadata(evt, relay); - const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ - elem('header', {className: 'mbox-header'}, [ - elem('small', {}, [ - elem('strong', {}, userName) - ]), - ]), - ` recommends server: ${evt.content}`, - ]); - return elemArticle([ - elem('div', {className: 'mbox-img'}, [img]), body - ], {className: 'mbox-recommend-server', data: {id: evt.id, pubkey: evt.pubkey}}); -} +const onEvent = (evt: Event, relay: string) => { + switch (evt.kind) { + case 0: + handleMetadata(evt, relay); + break; + case 1: + handleTextNote(evt, relay); + break; + case 2: + handleRecommendServer(evt, relay); + break; + case 3: + // handleContactList(evt, relay); + break; + case 7: + handleReaction(evt, relay); + default: + // console.log(`TODO: add support for event kind ${evt.kind}`/*, evt*/) + } +}; // subscribe and change view -function route(path: string) { +const route = (path: string) => { if (path === '/') { sub24hFeed(onEvent); view('/'); @@ -263,7 +262,7 @@ function route(path: string) { console.warn(`type ${type} not yet supported`); } } -} +}; // onload route(location.pathname); diff --git a/src/styles/cards.css b/src/styles/cards.css index b50fbce..37e061c 100644 --- a/src/styles/cards.css +++ b/src/styles/cards.css @@ -62,11 +62,6 @@ .mbox-header a { font-size: var(--font-small); } -.mbox-header time, -.mbox-username { - color: var(--color-accent); - cursor: pointer; -} .mbox-kind0-name { color: var(--color); diff --git a/src/worker.js b/src/worker.js index 97c14aa..c9007b2 100644 --- a/src/worker.js +++ b/src/worker.js @@ -1,7 +1,7 @@ import {getEventHash} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; -function mine(event, difficulty, timeout = 5) { +const 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}`); @@ -29,9 +29,9 @@ function mine(event, difficulty, timeout = 5) { return {id, ...event}; } } -} +}; -addEventListener('message', async (msg) => { +addEventListener('message', (msg) => { const {difficulty, event, timeout} = msg.data; try { const minedEvent = mine(event, difficulty, timeout); diff --git a/src/write.ts b/src/write.ts index 33594af..f551a3b 100644 --- a/src/write.ts +++ b/src/write.ts @@ -34,6 +34,19 @@ export const togglePublishView = () => { } }; +const appendReplyForm = (el: HTMLElement) => { + writeForm.before(elemShrink(writeInput)); + writeInput.blur(); + writeInput.style.removeProperty('height'); + el.after(writeForm); + if (writeInput.value && !writeInput.value.trimRight()) { + writeInput.value = ''; + } else { + requestAnimationFrame(() => updateElemHeight(writeInput)); + } + requestAnimationFrame(() => writeInput.focus()); +}; + const closeWriteInput = () => writeInput.blur(); export const openWriteInput = ( @@ -139,16 +152,3 @@ writeInput.addEventListener('input', () => { }); writeInput.addEventListener('blur', () => sendStatus.textContent = ''); - -function appendReplyForm(el: HTMLElement) { - writeForm.before(elemShrink(writeInput)); - writeInput.blur(); - writeInput.style.removeProperty('height'); - el.after(writeForm); - if (writeInput.value && !writeInput.value.trimRight()) { - writeInput.value = ''; - } else { - requestAnimationFrame(() => updateElemHeight(writeInput)); - } - requestAnimationFrame(() => writeInput.focus()); -} -- 2.46.2 From 2fa7cce511493f5806c7ad105e4c0a3983e501df Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 15 Apr 2023 09:30:40 +0200 Subject: [PATCH 30/34] about: fix styling --- src/about.html | 30 ++++++++++++++++-------------- src/styles/main.css | 2 +- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/about.html b/src/about.html index 869f122..a7c10fc 100644 --- a/src/about.html +++ b/src/about.html @@ -4,23 +4,25 @@ about / nostr - + -
-

nostr: notes and other stuff transmitted by relays

- this is a nostr web client.
- source code is at git.qcode.ch/nostr/nostrweb. -

- you are looking at version #[PKG_VERSION]#, built at git commit - #[GIT_COMMIT]#. -

-

- for more information about nostr protocol, check out - github.com/nostr-protocol/nostr#readme. -

- back to nostr.ch +
+
+

nostr: notes and other stuff transmitted by relays

+ this is a nostr web client.
+ source code is at git.qcode.ch/nostr/nostrweb. +

+ you are looking at version #[PKG_VERSION]#, built at git commit + #[GIT_COMMIT]#. +

+

+ for more information about nostr protocol, check out + github.com/nostr-protocol/nostr#readme. +

+ back to nostr.ch +
diff --git a/src/styles/main.css b/src/styles/main.css index 66bf78b..16da13b 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -103,7 +103,7 @@ img { } .text { - margin: var(--gap); + padding: 0 var(--gap); } .danger { -- 2.46.2 From 77711d655dc88df1111e88a3fd63b769d773e31c Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 15 Apr 2023 15:17:11 +0200 Subject: [PATCH 31/34] feed: ignore vmess protocol messages Drop encrypted vmess messages. --- src/main.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.ts b/src/main.ts index 7d88cf4..3d0cf77 100644 --- a/src/main.ts +++ b/src/main.ts @@ -152,6 +152,10 @@ const handleReply = (evt: EventWithNip19, relay: string) => { }; const handleTextNote = (evt: Event, relay: string) => { + if (evt.content.startsWith('vmess://') && !evt.content.includes(' ')) { + console.info('drop VMESS encrypted message'); + return; + } if (eventRelayMap[evt.id]) { eventRelayMap[evt.id] = [...(eventRelayMap[evt.id]), relay]; // TODO: just push? } else { -- 2.46.2 From 42fbd7c4c8dc09b8788847ba38511920e91eebc0 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 15 Apr 2023 15:19:26 +0200 Subject: [PATCH 32/34] feed: ignore ctrl click ctrl/command click should do the native browser thing and not be intercepted as normal click, i.e. ctrl click open-in-new-tab works --- src/main.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.ts b/src/main.ts index 3d0cf77..47ddd94 100644 --- a/src/main.ts +++ b/src/main.ts @@ -332,6 +332,10 @@ document.body.addEventListener('click', (event: MouseEvent) => { const target = event.target as HTMLElement; const a = target?.closest('a'); if (a) { + // dont intercept command-click + if (event.metaKey) { + return; + } handleLink(a, event); return; } -- 2.46.2 From c88cfa74bbc5a78703663cf01de5e1430ee4fca1 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 15 Apr 2023 15:33:44 +0200 Subject: [PATCH 33/34] refactor: improve view and move code to ui and notes cleanup code and move parts to ui.ts and notes.ts. simplify view and fix some weird animation issue, it should run pretty stable now. updated color and spacings. profile view now showing kind 0 name, but it is unnecessarily re-rendering. this part should probably go to a custom profil subscription callback in the future. keeping as is for now and refactor later. --- src/main.ts | 168 +++++++++++++++---------------------------- src/notes.ts | 16 +++++ src/profiles.ts | 25 ++++++- src/styles/cards.css | 18 ++--- src/styles/main.css | 18 +++-- src/styles/view.css | 36 ++++++++-- src/ui.ts | 84 ++++++++++++++++++++++ src/view.ts | 74 ++++++++++++------- 8 files changed, 279 insertions(+), 160 deletions(-) create mode 100644 src/notes.ts create mode 100644 src/ui.ts diff --git a/src/main.ts b/src/main.ts index 47ddd94..487b5f4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,88 +1,25 @@ import {Event, nip19} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; -import {elem, elemArticle, parseTextContent} from './utils/dom'; -import {bounce, dateTime, formatTime} from './utils/time'; +import {elem} from './utils/dom'; +import {bounce} from './utils/time'; import {isWssUrl} from './utils/url'; import {sub24hFeed, subNote, subProfile} from './subscriptions' -import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; -import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; +import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt} from './events'; +import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view'; import {closeSettingsView, config, toggleSettingsView} from './settings'; -import {getReactions, getReactionContents, handleReaction, handleUpvote} from './reactions'; +import {handleReaction, handleUpvote} from './reactions'; import {closePublishView, openWriteInput, togglePublishView} from './write'; -import {linkPreview} from './media'; -import {getMetadata, handleMetadata} from './profiles'; +import {handleMetadata, renderProfile} from './profiles'; +import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes'; +import {createTextNote, renderRecommendServer} from './ui'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ -type EventWithNip19 = Event & { - nip19: { - note: string; - npub: string; - } -}; -const textNoteList: Array = []; // could use indexDB - type EventRelayMap = { [eventId: string]: string[]; }; const eventRelayMap: EventRelayMap = {}; // eventId: [relay1, relay2] -type EventWithNip19AndReplyTo = EventWithNip19 & { - replyTo: string; -}; - -const replyList: Array = []; - -const createTextNote = (evt: EventWithNip19, relay: string) => { - const {host, img, name, time, userName} = getMetadata(evt, relay); - const replies = replyList.filter(({replyTo}) => replyTo === evt.id); - // const isLongContent = evt.content.trimRight().length > 280; - // const content = isLongContent ? evt.content.slice(0, 280) : evt.content; - const reactions = getReactions(evt.id); - const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey); - const replyFeed: Array = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : []; - const [content, {firstLink}] = parseTextContent(evt.content); - const buttons = 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: getReactionContents(evt.id).join(' '), - }), - elem('small', {data: {reactions: ''}}, reactions.length || ''), - ]), - ]); - const body = elem('div', {className: 'mbox-body'}, [ - elem('header', { - className: 'mbox-header', - title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${host}\n\nEvent-id: ${evt.id} - ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} - ${evt.content}` - }, [ - elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`}, name || userName), - ' ', - elem('a', {href: `/${evt.nip19.note}`}, elem('time', {dateTime: time.toISOString()}, formatTime(time))), - ]), - elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ - ...content, - (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : null, - ]), - buttons, - ]); - if (localStorage.getItem('reply_to') === evt.id) { - openWriteInput(buttons, evt.id); - } - return elemArticle([ - elem('div', {className: 'mbox-img'}, img), - body, - ...(replies[0] ? [elem('div', {className: 'mobx-replies'}, replyFeed.reverse())] : []), - ], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); -}; - const renderNote = ( evt: EventWithNip19, i: number, @@ -108,18 +45,46 @@ const hasEnoughPOW = ( }; const renderFeed = bounce(() => { - const now = Math.floor(Date.now() * 0.001); - textNoteList - // dont render notes from the future - .filter(note => note.created_at <= now) - // if difficulty filter is configured dont render notes with too little pow - .filter(note => !config.filterDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id))) - .sort(sortByCreatedAt) - .reverse() - .forEach(renderNote); + const view = getViewOptions(); + switch (view.type) { + case 'note': + textNoteList + .concat(replyList) + .filter(note => note.id === view.id) + .forEach(renderNote); + break; + case 'profile': + const isEvent = (evt?: T): evt is T => evt !== undefined; + [ + ...textNoteList + .filter(note => note.pubkey === view.id), + ...replyList.filter(reply => reply.pubkey === view.id) + .map(reply => textNoteList.find(note => note.id === reply.replyTo) || replyList.find(note => note.id === reply.replyTo) ) + .filter(isEvent) + ] + .sort(sortByCreatedAt) + .reverse() + .forEach(renderNote); // render in-reply-to + + renderProfile(view.id); + break; + case 'feed': + const now = Math.floor(Date.now() * 0.001); + textNoteList + .filter(note => { + // dont render notes from the future + if (note.created_at > now) return false; + // if difficulty filter is configured dont render notes with too little pow + return !config.filterDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id)) + }) + .sort(sortByCreatedAt) + .reverse() + .forEach(renderNote); + break; + } }, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s) -const renderReply = (evt: EventWithNip19AndReplyTo, relay: string) => { +const renderReply = (evt: EventWithNip19AndReplyTo) => { const parent = getViewElem(evt.replyTo); if (!parent) { // root article has not been rendered return; @@ -129,7 +94,7 @@ const renderReply = (evt: EventWithNip19AndReplyTo, relay: string) => { replyContainer = elem('div', {className: 'mobx-replies'}); parent.append(replyContainer); } - const reply = createTextNote(evt, relay); + const reply = createTextNote(evt, eventRelayMap[evt.id][0]); replyContainer.append(reply); setViewElem(evt.id, reply); }; @@ -148,7 +113,7 @@ const handleReply = (evt: EventWithNip19, relay: string) => { } const evtWithReplyTo = {replyTo, ...evt}; replyList.push(evtWithReplyTo); - renderReply(evtWithReplyTo, relay); + renderReply(evtWithReplyTo); }; const handleTextNote = (evt: Event, relay: string) => { @@ -183,27 +148,6 @@ config.rerenderFeed = () => { renderFeed(); }; -setInterval(() => { - document.querySelectorAll('time[datetime]').forEach((timeElem: HTMLTimeElement) => { - timeElem.textContent = formatTime(new Date(timeElem.dateTime)); - }); -}, 10000); - -const renderRecommendServer = (evt: Event, relay: string) => { - const {img, name, time, userName} = getMetadata(evt, relay); - const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ - elem('header', {className: 'mbox-header'}, [ - elem('small', {}, [ - elem('strong', {}, userName) - ]), - ]), - ` recommends server: ${evt.content}`, - ]); - return elemArticle([ - elem('div', {className: 'mbox-img'}, [img]), body - ], {className: 'mbox-recommend-server', data: {id: evt.id, pubkey: evt.pubkey}}); -}; - const handleRecommendServer = (evt: Event, relay: string) => { if (getViewElem(evt.id) || !isWssUrl(evt.content)) { return; @@ -246,7 +190,7 @@ const onEvent = (evt: Event, relay: string) => { const route = (path: string) => { if (path === '/') { sub24hFeed(onEvent); - view('/'); + view('/', {type: 'feed'}); } else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) { const {type, data} = nip19.decode(path.slice(1)); if (typeof data !== 'string') { @@ -256,15 +200,16 @@ const route = (path: string) => { switch(type) { case 'note': subNote(data, onEvent); - view(path); + view(path, {type: 'note', id: data}); break; case 'npub': subProfile(data, onEvent); - view(path); + view(path, {type: 'profile', id: data}); break; default: console.warn(`type ${type} not yet supported`); } + renderFeed(); } }; @@ -273,7 +218,6 @@ route(location.pathname); history.pushState({}, '', location.pathname); window.addEventListener('popstate', (event) => { - // console.log(`popstate: ${location.pathname}, state: ${JSON.stringify(event.state)}`); route(location.pathname); }); @@ -283,13 +227,17 @@ const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => { console.warn('expected anchor to have href attribute', a); return; } + closeSettingsView(); + closePublishView(); + if (href === location.pathname) { + e.preventDefault(); + return; + } if ( href === '/' || href.startsWith('/note') || href.startsWith('/npub') ) { - closeSettingsView(); - closePublishView(); route(href); history.pushState({}, '', href); e.preventDefault(); diff --git a/src/notes.ts b/src/notes.ts new file mode 100644 index 0000000..b94dfce --- /dev/null +++ b/src/notes.ts @@ -0,0 +1,16 @@ +import {Event} from 'nostr-tools'; + +export type EventWithNip19 = Event & { + nip19: { + note: string; + npub: string; + } +}; + +export const textNoteList: Array = []; // could use indexDB + +export type EventWithNip19AndReplyTo = EventWithNip19 & { + replyTo: string; +}; + +export const replyList: Array = []; diff --git a/src/profiles.ts b/src/profiles.ts index ccbe0d9..1c64302 100644 --- a/src/profiles.ts +++ b/src/profiles.ts @@ -1,6 +1,7 @@ import {Event} from 'nostr-tools'; import {elem, elemCanvas} from './utils/dom'; import {getHost, getNoxyUrl} from './utils/url'; +import {getViewContent, getViewElem} from './view'; import {validatePow} from './events'; import {parseContent} from './media'; @@ -66,7 +67,8 @@ const setMetadata = ( const name = user.metadata[relay].name || user.name || ''; if (name) { document.body - .querySelectorAll(`[data-pubkey="${evt.pubkey}"] .mbox-username:not(.mbox-kind0-name)`) + // TODO: this should not depend on specific DOM structure, move pubkey info on username element + .querySelectorAll(`[data-pubkey="${evt.pubkey}"] > .mbox-body > header .mbox-username:not(.mbox-kind0-name)`) .forEach((username: HTMLElement) => { username.textContent = name; username.classList.add('mbox-kind0-name'); @@ -103,9 +105,11 @@ export const handleMetadata = (evt: Event, relay: string) => { setMetadata(evt, relay, metadata); }; +export const getProfile = (pubkey: string) => userList.find(user => user.pubkey === pubkey); + export const getMetadata = (evt: Event, relay: string) => { const host = getHost(relay); - const user = userList.find(user => user.pubkey === evt.pubkey); + const user = getProfile(evt.pubkey); const userImg = user?.picture; const name = user?.metadata[relay]?.name || user?.name; const userName = name || evt.pubkey.slice(0, 8); @@ -156,3 +160,20 @@ export const getMetadata = (evt: Event, relay: string) => { // ]); // return renderArticle([img, body], {className: 'mbox-updated-contact', data: {id: evt.id, pubkey: evt.pubkey, relay}}); // } + +export const renderProfile = (id: string) => { + const content = getViewContent(); + const header = getViewElem(id); + if (!content || !header) { + return; + } + const profile = getProfile(id); + if (profile && profile.name) { + const h1 = header.querySelector('h1'); + if (h1) { + h1.textContent = profile.name; + } else { + header.prepend(elem('h1', {}, profile.name)); + } + } +}; \ No newline at end of file diff --git a/src/styles/cards.css b/src/styles/cards.css index 37e061c..b94c656 100644 --- a/src/styles/cards.css +++ b/src/styles/cards.css @@ -1,8 +1,5 @@ /* https://developer.mozilla.org/en-US/docs/Web/CSS/Layout_cookbook/Media_objects */ .mbox { - --profileimg-size: 4rem; - --profileimg-size-half: 2rem; - --profileimg-size-quarter: 1rem; align-items: center; display: flex; flex-direction: row; @@ -54,9 +51,6 @@ } .mbox-header { - flex-basis: calc(100% - var(--profileimg-size) - var(--gap-half)); - flex-grow: 0; - flex-shrink: 1; margin-top: 0; } .mbox-header a { @@ -121,21 +115,21 @@ display: block; height: 200vh; left: var(--profileimg-size-half); - margin-left: -.2rem; + margin-left: -.1rem; position: absolute; top: -200vh; - width: .4rem; + width: .2rem; } .mobx-replies .mbox .mbox::before { background: none; border-color: var(--bgcolor-inactive);; border-style: solid; - border-width: 0 0 .4rem .4rem; + border-width: 0 0 .2rem .2rem; content: ""; display: block; height: var(--profileimg-size-quarter); left: calc(-1 * var(--profileimg-size-quarter)); - margin-left: -.2rem; + margin-left: -.1rem; position: absolute; top: 0; width: .8rem; @@ -147,10 +141,10 @@ display: block; height: 100vh; left: calc(-1 * var(--profileimg-size-quarter)); - margin-left: -.2rem; + margin-left: -.1rem; position: absolute; top: -100vh; - width: .4rem; + width: .2rem; } /* support visualisation of 3 levels of thread nesting, rest render flat without line */ .mbox .mobx-replies .mobx-replies::before, diff --git a/src/styles/main.css b/src/styles/main.css index 16da13b..bd32aed 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -5,9 +5,10 @@ @import "error.css"; :root { + --content-width: min(100% - 2.4rem, 96ch); /* 5px auto Highlight */ --focus-border-color: rgb(0, 122, 255); - --focus-border-radius: 2px; + --focus-border-radius: .2rem; --focus-outline-color: rgb(192, 227, 252); --focus-outline-offset: 2px; --focus-outline-style: solid; @@ -16,7 +17,9 @@ --font-small: 1.2rem; --gap: 2.4rem; --gap-half: 1.2rem; - --content-width: min(100% - 2.4rem, 96ch); + --profileimg-size: 4rem; + --profileimg-size-half: 2rem; + --profileimg-size-quarter: 1rem; } ::selection { @@ -30,7 +33,8 @@ @media (prefers-color-scheme: light) { html { - --bgcolor: #fdfefa; + --bgcolor: #fff; + --bgcolor-nav: gainsboro; --bgcolor-accent: #7badfc; --bgcolor-danger: rgb(225, 40, 40); --bgcolor-danger-input: rgba(255 255 255 / .85); @@ -45,6 +49,7 @@ @media (prefers-color-scheme: dark) { html { --bgcolor: #191919; + --bgcolor-nav: darkslateblue; --bgcolor-accent: rgb(16, 93, 176); --bgcolor-danger: rgb(169, 0, 0); --bgcolor-danger-input: rgba(0 0 0 / .5); @@ -74,6 +79,7 @@ body { color: var(--color); font-size: 1.6rem; line-height: 1.5; + word-break: break-all; } html, body { @@ -119,9 +125,11 @@ a:focus { outline: var(--focus-outline); outline-offset: 0; } - a:visited { - color: darkmagenta; + color: darkslateblue; +} +nav a:visited { + color: inherit; } img[alt] { diff --git a/src/styles/view.css b/src/styles/view.css index c1d4524..2b2004f 100644 --- a/src/styles/view.css +++ b/src/styles/view.css @@ -20,18 +20,18 @@ main { } aside { - z-index: 2; + z-index: 4; } nav { - background-color: indigo; + background-color: var(--bgcolor-nav); display: flex; flex-direction: row; flex-grow: 1; flex-shrink: 0; - justify-content: space-around; + justify-content: space-between; overflow-y: auto; - padding: 1rem 1.5rem; + padding: 0 1.5rem; user-select: none; -webkit-user-select: none; } @@ -46,6 +46,19 @@ nav { justify-content: space-between; } } +nav a, +nav button { + --bgcolor-accent: transparent; + --border-color: transparent; + border-radius: 0; + padding: 1rem; +} +@media (orientation: landscape) { + nav a, + nav button { + padding: 2rem 0; + } +} .view { background-color: var(--bgcolor); @@ -61,20 +74,25 @@ nav { transition: transform .3s cubic-bezier(.465,.183,.153,.946); width: 100%; will-change: transform; + z-index: 2; } @media (orientation: landscape) { .view { transition: opacity .3s cubic-bezier(.465,.183,.153,.946); } } +.view.view-next { + z-index: 3; +} +.view.view-prev { + z-index: 1; +} @media (orientation: portrait) { .view.view-next { transform: translateX(100%); } .view.view-prev { - position: relative; transform: translateX(-20%); - z-index: 0; } } @media (orientation: landscape) { @@ -91,7 +109,7 @@ nav { flex-grow: 1; margin-inline: auto; overflow-y: auto; - padding: var(--gap-half) 0; + padding: var(--gap-half) 0 0 0; width: 100%; } main .content { @@ -108,3 +126,7 @@ nav a { text-align: center; text-decoration: none; } + +.content > header { + padding: 3rem 3rem 3rem calc(var(--profileimg-size) + var(--gap)); +} diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..862b5a6 --- /dev/null +++ b/src/ui.ts @@ -0,0 +1,84 @@ +import {Event} from 'nostr-tools'; +import {elem, elemArticle, parseTextContent} from './utils/dom'; +import {dateTime, formatTime} from './utils/time'; +import {validatePow, sortByCreatedAt} from './events'; +import {setViewElem} from './view'; +import {config} from './settings'; +import {getReactions, getReactionContents} from './reactions'; +import {openWriteInput} from './write'; +import {linkPreview} from './media'; +import {getMetadata} from './profiles'; +import {EventWithNip19, replyList} from './notes'; + +setInterval(() => { + document.querySelectorAll('time[datetime]').forEach((timeElem: HTMLTimeElement) => { + timeElem.textContent = formatTime(new Date(timeElem.dateTime)); + }); +}, 10000); + +export const createTextNote = ( + evt: EventWithNip19, + relay: string, +) => { + const {host, img, name, time, userName} = getMetadata(evt, relay); + const replies = replyList.filter(({replyTo}) => replyTo === evt.id); + // const isLongContent = evt.content.trimRight().length > 280; + // const content = isLongContent ? evt.content.slice(0, 280) : evt.content; + const reactions = getReactions(evt.id); + const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey); + const [content, {firstLink}] = parseTextContent(evt.content); + const buttons = 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: getReactionContents(evt.id).join(' '), + }), + elem('small', {data: {reactions: ''}}, reactions.length || ''), + ]), + ]); + if (localStorage.getItem('reply_to') === evt.id) { + openWriteInput(buttons, evt.id); + } + const replyFeed: Array = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : []; + return elemArticle([ + elem('div', {className: 'mbox-img'}, img), + elem('div', {className: 'mbox-body'}, [ + elem('header', { + className: 'mbox-header', + title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${host}\n\nEvent-id: ${evt.id} + ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} + ${evt.content}` + }, [ + elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`}, name || userName), + ' ', + elem('a', {href: `/${evt.nip19.note}`}, elem('time', {dateTime: time.toISOString()}, formatTime(time))), + ]), + elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ + ...content, + (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : null, + ]), + buttons, + ]), + ...(replies[0] ? [elem('div', {className: 'mobx-replies'}, replyFeed.reverse())] : []), + ], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); +}; + +export const renderRecommendServer = (evt: Event, relay: string) => { + const {img, name, time, userName} = getMetadata(evt, relay); + const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ + elem('header', {className: 'mbox-header'}, [ + elem('small', {}, [ + elem('strong', {}, userName) + ]), + ]), + ` recommends server: ${evt.content}`, + ]); + return elemArticle([ + elem('div', {className: 'mbox-img'}, [img]), body + ], {className: 'mbox-recommend-server', data: {id: evt.id, pubkey: evt.pubkey}}); +}; diff --git a/src/view.ts b/src/view.ts index 79d8b53..445b07f 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,12 +1,25 @@ import {elem} from './utils/dom'; +type ViewOptions = { + type: 'feed' +} | { + type: 'note'; + id: string; +} | { + type: 'profile'; + id: string; +}; + +type DOMMap = { + [id: string]: HTMLElement +}; + type Container = { id: string; + options: ViewOptions, view: HTMLElement; content: HTMLDivElement; - dom: { - [eventId: string]: HTMLElement - } + dom: DOMMap; }; const containers: Array = []; @@ -22,37 +35,56 @@ export const clearView = () => { getViewContent().replaceChildren(); }; -export const getViewElem = (eventId: string) => { - return containers[activeContainerIndex]?.dom[eventId]; +export const getViewElem = (id: string) => { + return containers[activeContainerIndex]?.dom[id]; }; -export const setViewElem = (eventId: string, node: HTMLElement) => { +export const setViewElem = (id: string, node: HTMLElement) => { const container = containers[activeContainerIndex]; if (container) { - container.dom[eventId] = node; + container.dom[id] = node; } return node; }; -const mainContainer = document.querySelector('main'); +const mainContainer = document.querySelector('main') as HTMLElement; -const getContainer = (route: string) => { - let container = containers.find(c => c.id === route); - if (container) { - return container; - } +const createContainer = ( + route: string, + options: ViewOptions, +) => { const content = elem('div', {className: 'content'}); + const dom: DOMMap = {}; + switch (options.type) { + case 'profile': + const header = elem('header', {}, + elem('small', {}, route) + ); + dom[options.id] = header; + content.append(header); + break; + case 'note': + break; + case 'feed': + break; + } const view = elem('section', {className: 'view'}, [content]); - mainContainer?.append(view); - container = {id: route, view, content, dom: {}}; + const container = {id: route, options, view, content, dom}; + mainContainer.append(view); containers.push(container); return container; }; -export const view = (route: string) => { +type GetViewOptions = () => ViewOptions; + +export const getViewOptions: GetViewOptions = () => containers[activeContainerIndex]?.options || {type: 'feed'}; + +export const view = ( + route: string, + options: ViewOptions, +) => { const active = containers[activeContainerIndex]; - active?.view.classList.remove('view-active'); - const nextContainer = getContainer(route); + const nextContainer = containers.find(c => c.id === route) || createContainer(route, options); const nextContainerIndex = containers.indexOf(nextContainer); if (nextContainerIndex === activeContainerIndex) { return; @@ -63,12 +95,6 @@ export const view = (route: string) => { requestAnimationFrame(() => { requestAnimationFrame(() => { nextContainer.view.classList.remove('view-next', 'view-prev'); - nextContainer.view.classList.add('view-active'); - }); - // // console.log(activeContainerIndex, nextContainerIndex); - getViewContent()?.querySelectorAll('.view-prev').forEach(prev => { - prev.classList.remove('view-prev'); - prev.classList.add('view-next'); }); active?.view.classList.add(nextContainerIndex < activeContainerIndex ? 'view-next' : 'view-prev'); activeContainerIndex = nextContainerIndex; -- 2.46.2 From 683b50012185c4ebfe3eea2141f2840dfbe0aba4 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 16 Apr 2023 17:38:29 +0200 Subject: [PATCH 34/34] cleanup - remove X icon placeholder - remove weird unicode whitespace - delete comment out code --- src/index.html | 2 +- src/settings.ts | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/index.html b/src/index.html index fd3e039..42e5f25 100644 --- a/src/index.html +++ b/src/index.html @@ -102,7 +102,7 @@
diff --git a/src/settings.ts b/src/settings.ts index 589e092..05edefe 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -174,17 +174,14 @@ privateTgl.addEventListener('click', () => { privateKeyInput.type = privateKeyInput.type === 'text' ? 'password' : 'text'; }); -privateKeyInput.value = localStorage.getItem('private_key') || ''; -pubKeyInput.value = localStorage.getItem('pub_key') || ''; +privateKeyInput.value = localStorage.getItem('private_key') || ''; +pubKeyInput.value = localStorage.getItem('pub_key') || ''; // profile const profileForm = document.querySelector('form[name="profile"]') as HTMLFormElement; const profileSubmit = profileForm.querySelector('button[type="submit"]') as HTMLButtonElement; const profileStatus = document.querySelector('#profilestatus') as HTMLElement; -// const onProfileError = err => { -// profileStatus.hidden = false; -// profileStatus.textContent = err.message -// }; + profileForm.addEventListener('input', (e) => { if (e.target instanceof HTMLElement) { if (e.target?.nodeName === 'TEXTAREA') { -- 2.46.2