From f33c787a9a368f395b01ff548383e7ccee8a9d07 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Tue, 8 Nov 2022 20:37:33 +0100 Subject: [PATCH 01/23] updated some styling - change max-width to apply ti all tabs instead of just forms - use monospace font for buttons and labels - add tiny 2px border radius to buttons and prepare bottombar --- src/form.css | 12 +++++++++++- src/tabs.css | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/form.css b/src/form.css index 61a186b..ec2ff2b 100644 --- a/src/form.css +++ b/src/form.css @@ -1,5 +1,4 @@ form { - max-width: 72ch; } input, @@ -15,6 +14,7 @@ button, label { color: var(--bgcolor-accent); display: block; + font-family: monospace; font-size: 1.6rem; margin-bottom: 0; padding: 1.3rem 1.8rem; @@ -41,6 +41,15 @@ input[type="text"]:focus { justify-content: flex-end; min-height: 3.2rem; } +.buttons-bottombar { + bottom: 2rem; + padding: 0 1rem; + position: sticky; +} +.buttons-bottombar button { + font-size: 2rem; + margin: 1rem; +} .button-inline { background: transparent; @@ -52,6 +61,7 @@ input[type="text"]:focus { button { background-color: var(--bgcolor-accent); border: none; + border-radius: .2rem; color: white; cursor: pointer; } diff --git a/src/tabs.css b/src/tabs.css index 02cf5a3..10194e1 100644 --- a/src/tabs.css +++ b/src/tabs.css @@ -1,5 +1,6 @@ .tabs { position: relative; + max-width: 72ch; min-height: 200px; } @@ -48,5 +49,5 @@ opacity: 0; position: absolute; right: 0; - top: 5em; + top: 10rem; } From bac4b9c5e6fcb56d5d70ac11030337699bf8fc97 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Tue, 8 Nov 2022 20:42:55 +0100 Subject: [PATCH 02/23] change everything - reverse timeline - add publish test button - renamed variables to pubkey, privatekey - subscribe to self (currently only on load) - listen to more events before closing --- src/index.html | 5 ++- src/main.js | 86 +++++++++++++++++++++++++++++++++++--------------- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/src/index.html b/src/index.html index cec3c31..e5c343e 100644 --- a/src/index.html +++ b/src/index.html @@ -13,6 +13,9 @@
+
+ +
@@ -54,7 +57,7 @@
- + diff --git a/src/main.js b/src/main.js index cef8320..efcfaa7 100644 --- a/src/main.js +++ b/src/main.js @@ -1,13 +1,16 @@ -import {relayPool, generatePrivateKey, getPublicKey} from 'nostr-tools'; +import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; import {elem} from './domutil.js'; const pool = relayPool(); -// pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); -pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); -pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); -pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); -pool.addRelay('wss://relay.damus.io', {read: true, write: true}); - +pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); +// pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); +// pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); +// pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); +// pool.addRelay('wss://relay.damus.io', {read: true, write: true}); +// read only +// pool.addRelay('wss://nostr.rocks', {read: true, write: false}); +// pool.addRelay('wss://nostr-relay.wlvs.space', {read: true, write: false}); +// pool.addRelay('wss://nostr-relay.untethr.me', {read: true, write: false}); const feedlist = document.querySelector('#feedlist'); @@ -18,8 +21,9 @@ const dateTime = new Intl.DateTimeFormat(navigator.language, { const userList = []; let max = 0; + function onEvent(evt, relay) { - if (max++ >= 7) { + if (max++ >= 23) { return subscription.unsub(); } switch (evt.kind) { @@ -42,6 +46,8 @@ function onEvent(evt, relay) { } } +const pubkey = localStorage.getItem('pub_key') + const subscription = pool.sub({ cb: onEvent, filter: { @@ -49,6 +55,7 @@ const subscription = pool.sub({ '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // quark 'a6057742e73ff93b89587c27a74edf2cdab86904291416e90dc98af1c5f70cfa', // mosc '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', // fiatjaf + pubkey, // me // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' // jb55 ] } @@ -80,7 +87,7 @@ function renderRecommendServer(evt, relay) { function rendernArticle(content) { const art = elem('article', {className: 'mbox'}, content); - feedlist.append(art); + feedlist.prepend(art); } function getMetadata(evt, relay) { @@ -117,8 +124,35 @@ function setMetadata(userList, relay, evt, content) { } } -// settings +// check pool.status + +// publish +const publish = document.querySelector('#publish'); +publish.addEventListener('click', async () => { + const pubkey = localStorage.getItem('pub_key'); + const privatekey = localStorage.getItem('private_key'); + if (!pubkey || !privatekey) { + return console.warn('no pubkey/privatekey'); + } + const newEvent = { + kind: 1, + pubkey, + content: 'geil', + tags: [], + created_at: Math.floor(Date.now() * 0.001), + }; + const sig = await signEvent(newEvent, privatekey); + const ev = await pool.publish({...newEvent, sig}, (status, url) => { + if (status === 0) { + console.log(`publish request sent to ${url}`) + } + if (status === 1) { + console.log(`event published by ${url}`, ev) + } + }); +}); +// settings const form = document.querySelector('form[name="settings"]'); const privateKeyInput = form.querySelector('#privatekey'); const pubKeyInput = form.querySelector('#pubkey'); @@ -128,33 +162,33 @@ const importBtn = form.querySelector('button[name="import"]'); const privateTgl = form.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; + 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 pubKey = pubKeyInput.value; - if (validKeys(privateKey, pubKey)) { - localStorage.setItem('privateKey', privateKey); - localStorage.setItem('pubKey', pubKey); - statusMessage.textContent = 'private-key saved in local storage!'; + const privatekey = privateKeyInput.value; + const pubkey = pubKeyInput.value; + if (validKeys(privatekey, pubkey)) { + localStorage.setItem('private_key', privatekey); + localStorage.setItem('pub_key', pubkey); + statusMessage.textContent = 'stored private and public key locally!'; statusMessage.hidden = false; } }); form.addEventListener('input', () => validKeys(privateKeyInput.value, pubKeyInput.value)); -function validKeys(privateKey, pubKey) { - if (pubKey && privateKey) { +function validKeys(privatekey, pubkey) { + if (pubkey && privatekey) { try { - if (getPublicKey(privateKey) === pubKey) { + if (getPublicKey(privatekey) === pubkey) { statusMessage.hidden = true; statusMessage.textContent = 'public-key corresponds to private-key'; importBtn.removeAttribute('disabled'); @@ -175,5 +209,5 @@ privateTgl.addEventListener('click', () => { privateKeyInput.type = privateKeyInput.type === 'text' ? 'password' : 'text'; }); -privateKeyInput.value = localStorage.getItem('privateKey'); -pubKeyInput.value = localStorage.getItem('pubKey'); +privateKeyInput.value = localStorage.getItem('private_key'); +pubKeyInput.value = localStorage.getItem('pub_key'); From 44568060c05b4032975ef94c3d4302e456ebb532 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Wed, 9 Nov 2022 20:54:25 +0100 Subject: [PATCH 03/23] feed: add inline form for writing new messages Just a prototype, not functioning yet --- src/form.css | 52 +++++++++++++++++++++++++++++++++----------------- src/index.html | 12 +++++++++--- 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/form.css b/src/form.css index ec2ff2b..508e06f 100644 --- a/src/form.css +++ b/src/form.css @@ -3,7 +3,7 @@ form { input, textarea { - color: var(--bgcolor-accent); + color: var(--color); font-family: monospace; font-size: 1.6rem; margin-bottom: 1.2rem; @@ -12,7 +12,6 @@ textarea { button, label { - color: var(--bgcolor-accent); display: block; font-family: monospace; font-size: 1.6rem; @@ -21,12 +20,16 @@ label { text-indent: 0; } +label { + color: var(--bgcolor-accent); +} + input[type="password"], input[type="text"] { border: .2rem solid #b7b7b7; border-radius: .2rem; display: block; - outline-color: rgb(102, 102, 102); + margin: 0; width: 100%; } input[type="password"]:focus, @@ -39,16 +42,19 @@ input[type="text"]:focus { align-items: center; display: flex; justify-content: flex-end; + margin-top: 2rem; min-height: 3.2rem; } -.buttons-bottombar { - bottom: 2rem; - padding: 0 1rem; - position: sticky; + +button { + background-color: var(--bgcolor-accent); + border: none; + border-radius: .2rem; + cursor: pointer; + outline-offset: 1px; } -.buttons-bottombar button { - font-size: 2rem; - margin: 1rem; + +button:focus { } .button-inline { @@ -58,14 +64,6 @@ input[type="text"]:focus { padding: .3rem; } -button { - background-color: var(--bgcolor-accent); - border: none; - border-radius: .2rem; - color: white; - cursor: pointer; -} - button:disabled { background-color: var(--bgcolor-inactive); cursor: default; @@ -80,3 +78,21 @@ button:disabled { flex-grow: 1; padding: 1rem 1.8rem; } + +.form-inline { + display: flex; + flex-grow: 1; + gap: 1rem; +} + +.form-inline input[type=text] { + flex-grow: 1; +} + +.form-inline button { + flex-grow: 0; +} + +.focus-active { + +} diff --git a/src/index.html b/src/index.html index e5c343e..c694d77 100644 --- a/src/index.html +++ b/src/index.html @@ -12,10 +12,16 @@
+
+ +
+
+ + +
+
+
-
- -
From f7eb5ff7f6a981b3ce624edd1c4b93c1609e4eee Mon Sep 17 00:00:00 2001 From: OFF0 Date: Fri, 11 Nov 2022 19:25:47 +0100 Subject: [PATCH 04/23] contacts: trying to create a contactlist - disabled user picture, loading external resources leaks metadata and is bad for privacy. - refactored code to share profile rendering. - wip: keep a contact list to display user follows otheruser --- src/main.js | 61 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/src/main.js b/src/main.js index efcfaa7..362a6fc 100644 --- a/src/main.js +++ b/src/main.js @@ -1,16 +1,15 @@ import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; import {elem} from './domutil.js'; - +// curl -H 'accept: application/nostr+json' https://nostr.x1ddos.ch const pool = relayPool(); pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); // pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); -// pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); // pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); +// pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); // pool.addRelay('wss://relay.damus.io', {read: true, write: true}); // read only // pool.addRelay('wss://nostr.rocks', {read: true, write: false}); // pool.addRelay('wss://nostr-relay.wlvs.space', {read: true, write: false}); -// pool.addRelay('wss://nostr-relay.untethr.me', {read: true, write: false}); const feedlist = document.querySelector('#feedlist'); @@ -41,19 +40,23 @@ function onEvent(evt, relay) { case 2: renderRecommendServer(evt, relay); break; + case 3: + updateContactList(evt, relay); + break; default: console.log(`TODO: add support for event kind ${evt.kind}`, evt) } } const pubkey = localStorage.getItem('pub_key') +console.log({pubkey}) const subscription = pool.sub({ cb: onEvent, filter: { authors: [ - '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // quark - 'a6057742e73ff93b89587c27a74edf2cdab86904291416e90dc98af1c5f70cfa', // mosc + // '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // quark + // 'a6057742e73ff93b89587c27a74edf2cdab86904291416e90dc98af1c5f70cfa', // mosc '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', // fiatjaf pubkey, // me // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' // jb55 @@ -62,12 +65,18 @@ const subscription = pool.sub({ }); function renderTextNote(evt, relay) { + if (evt.tags.length) { + console.log('has tags', evt) + } const [host, img, time, userName] = getMetadata(evt, relay); - const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ - elem('header', {className: 'mbox-header'}, [ - elem('strong', {}, userName), - elem('small', {},` on ${host}`), - ]), + const style = evt.tags.some(tag => tag[0] === 'e') && 'padding-left: 2rem'; + const body = elem('div', { + className: 'mbox-body', + title: dateTime.format(time), + ...(style && {style}) + }, [ + renderProfile(userName, host), + elem('div', {}, evt.id), evt.content // text ]); rendernArticle([img, body]); @@ -76,15 +85,18 @@ function renderTextNote(evt, relay) { function renderRecommendServer(evt, relay) { const [host, img, time, userName] = getMetadata(evt, relay); const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ - elem('header', {className: 'mbox-header'}, [ - elem('strong', {}, userName), - elem('small', {},` on ${host}`), - ]), + renderProfile(userName, host), `recommends server: ${evt.content}` ]); rendernArticle([img, body]); } +function renderProfile(userName, host) { + return elem('header', {className: 'mbox-header'}, [ + elem('small', {}, [elem('strong', {}, userName), ` on ${host}`]), + ]); +} + function rendernArticle(content) { const art = elem('article', {className: 'mbox'}, content); feedlist.prepend(art); @@ -93,7 +105,7 @@ function rendernArticle(content) { function getMetadata(evt, relay) { const {host} = new URL(relay); const user = userList.find(user => user.pubkey === evt.pubkey); - const userImg = user?.metadata[relay]?.picture || 'bubble.svg'; + const userImg = /*user?.metadata[relay]?.picture || */'bubble.svg'; // enable pic once we have proxy const userName = user?.metadata[relay]?.name || evt.pubkey.slice(0, 8); const userAbout = user?.metadata[relay]?.about || ''; const img = elem('img', { @@ -106,6 +118,8 @@ function getMetadata(evt, relay) { return [host, img, time, userName]; } +const tempContactList = {}; + function setMetadata(userList, relay, evt, content) { const user = userList.find(u => u.pubkey === evt.pubkey); if (!user) { @@ -122,6 +136,23 @@ function setMetadata(userList, relay, evt, content) { ...content, }; } + 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 updateContactList(evt, relay) { + 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]; + } } // check pool.status From 7621989b4311c760592c245b8d7692ba229b0e0d Mon Sep 17 00:00:00 2001 From: OFF0 Date: Fri, 11 Nov 2022 19:26:40 +0100 Subject: [PATCH 05/23] layout update - limit max width of feed content - better profile image positioning --- src/cards.css | 12 ++++++------ src/tabs.css | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/cards.css b/src/cards.css index d0d12ae..a926b72 100644 --- a/src/cards.css +++ b/src/cards.css @@ -3,16 +3,16 @@ align-items: center; display: flex; flex-direction: row; - flex-wrap: wrap; + flex-wrap: nowrap; margin-bottom: 1rem; } .mbox .mbox-img { - align-self: flex-start; - flex-basis: 64px; - height: 64px; + align-self: center; + flex-basis: 4ch; + height: 4ch; margin-right: 1rem; - width: 64px; + max-width: 4ch; } .mbox .mbox-header { @@ -27,5 +27,5 @@ flex-basis: calc(100% - 64px - 1rem); flex-grow: 0; flex-shrink: 1; - max-width: 84ch; + max-width: 96ch; } diff --git a/src/tabs.css b/src/tabs.css index 10194e1..616d486 100644 --- a/src/tabs.css +++ b/src/tabs.css @@ -1,6 +1,6 @@ .tabs { position: relative; - max-width: 72ch; + max-width: 96ch; min-height: 200px; } From 69afa6320177a4d1b2b24bb3322bdad5332152a2 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 12 Nov 2022 08:06:54 +0100 Subject: [PATCH 06/23] feed: style text note reply Indent textNotes that are replies to an event. --- src/cards.css | 4 ++++ src/index.html | 2 +- src/main.js | 21 +++++++++------------ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/cards.css b/src/cards.css index a926b72..0bae9ed 100644 --- a/src/cards.css +++ b/src/cards.css @@ -7,6 +7,10 @@ margin-bottom: 1rem; } +.mbox-reply { + padding-left: 4ch; +} + .mbox .mbox-img { align-self: center; flex-basis: 4ch; diff --git a/src/index.html b/src/index.html index c694d77..7316539 100644 --- a/src/index.html +++ b/src/index.html @@ -21,7 +21,7 @@ -
+
diff --git a/src/main.js b/src/main.js index 362a6fc..1e6b623 100644 --- a/src/main.js +++ b/src/main.js @@ -11,13 +11,11 @@ pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); // pool.addRelay('wss://nostr.rocks', {read: true, write: false}); // pool.addRelay('wss://nostr-relay.wlvs.space', {read: true, write: false}); -const feedlist = document.querySelector('#feedlist'); const dateTime = new Intl.DateTimeFormat(navigator.language, { dateStyle: 'full', timeStyle: 'long', }); - const userList = []; let max = 0; @@ -69,17 +67,13 @@ function renderTextNote(evt, relay) { console.log('has tags', evt) } const [host, img, time, userName] = getMetadata(evt, relay); - const style = evt.tags.some(tag => tag[0] === 'e') && 'padding-left: 2rem'; - const body = elem('div', { - className: 'mbox-body', - title: dateTime.format(time), - ...(style && {style}) - }, [ + const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ renderProfile(userName, host), elem('div', {}, evt.id), evt.content // text ]); - rendernArticle([img, body]); + const isReply = evt.tags.some(tag => tag[0] === 'e'); + rendernArticle([img, body], isReply && {className: 'mbox-reply'}); } function renderRecommendServer(evt, relay) { @@ -97,9 +91,12 @@ function renderProfile(userName, host) { ]); } -function rendernArticle(content) { - const art = elem('article', {className: 'mbox'}, content); - feedlist.prepend(art); +const feedContainer = document.querySelector('#feed'); + +function rendernArticle(content, props) { + const className = ['mbox', props?.className].join(' '); + const art = elem('article', {...props, className}, content); + feedContainer.prepend(art); } function getMetadata(evt, relay) { From cab1d14603bd68d677021edd23faca18108b5425 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 12 Nov 2022 08:46:51 +0100 Subject: [PATCH 07/23] feed: add reply to info Add reply to event id and if available relay info. Relay info is not always available, see positional e tags (DEPRECATED) NIP-10. --- src/main.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main.js b/src/main.js index 1e6b623..ef4bcc9 100644 --- a/src/main.js +++ b/src/main.js @@ -62,17 +62,20 @@ const subscription = pool.sub({ } }); +const hasEventTag = tag => tag[0] === 'e'; +const getShortTagId = tag => `${tag[1].slice(0, 7)}${tag[2] ? '@' + tag[2] : ''}`; + function renderTextNote(evt, relay) { if (evt.tags.length) { console.log('has tags', evt) } const [host, img, time, userName] = getMetadata(evt, relay); + const isReply = evt.tags.some(hasEventTag); const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ renderProfile(userName, host), - elem('div', {}, evt.id), + elem('div', {}, isReply ? `reply to ${evt.tags.filter(hasEventTag).map(getShortTagId).join(' and ')}` : evt.id), evt.content // text ]); - const isReply = evt.tags.some(tag => tag[0] === 'e'); rendernArticle([img, body], isReply && {className: 'mbox-reply'}); } From 380b79038d0e6c6af64f851f17b57d590604599a Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 12 Nov 2022 10:41:29 +0100 Subject: [PATCH 08/23] feed: change recommend server styling to show on oneline As recommend servers is secondary meta information, it is enough to show it smaller and on one line. --- src/cards.css | 20 +++++++++++++++----- src/main.css | 5 ++++- src/main.js | 4 ++-- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/cards.css b/src/cards.css index 0bae9ed..38db3b9 100644 --- a/src/cards.css +++ b/src/cards.css @@ -19,17 +19,27 @@ max-width: 4ch; } -.mbox .mbox-header { +.mbox-body { + color: var(--color-accent); flex-basis: calc(100% - 64px - 1rem); flex-grow: 0; flex-shrink: 1; - margin-top: 0; + max-width: 96ch; } -.mbox .mbox-body { - color: var(--color-accent); +.mbox-header { flex-basis: calc(100% - 64px - 1rem); flex-grow: 0; flex-shrink: 1; - max-width: 96ch; + margin-top: 0; +} + +.mbox-recommend-server .mbox-body { + display: block; + flex-basis: 100%; + font-size: var(--font-small); +} + +.mbox-recommend-server .mbox-header { + display: inline; } diff --git a/src/main.css b/src/main.css index bc1312c..c90e3e9 100644 --- a/src/main.css +++ b/src/main.css @@ -2,6 +2,9 @@ @import "cards.css"; @import "form.css"; +:root { + --font-small: 1.2rem; +} ::selection { background: #ff79f9; @@ -56,7 +59,7 @@ body { } small { - font-size: 1.2rem; + font-size: var(--font-small); } *, ::after, ::before { diff --git a/src/main.js b/src/main.js index ef4bcc9..1a02c0d 100644 --- a/src/main.js +++ b/src/main.js @@ -85,12 +85,12 @@ function renderRecommendServer(evt, relay) { renderProfile(userName, host), `recommends server: ${evt.content}` ]); - rendernArticle([img, body]); + rendernArticle([img, body], {className: 'mbox-recommend-server'}); } function renderProfile(userName, host) { return elem('header', {className: 'mbox-header'}, [ - elem('small', {}, [elem('strong', {}, userName), ` on ${host}`]), + elem('small', {}, [elem('strong', {}, userName), ` on ${host} `]), ]); } From 5d701c8767f8a1df0d05217a24b01799b81d7e3f Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 12 Nov 2022 11:47:03 +0100 Subject: [PATCH 09/23] utils: append string child directly Strings can be appended directly without spreading. --- src/domutil.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/domutil.js b/src/domutil.js index bb78edb..a3ac911 100644 --- a/src/domutil.js +++ b/src/domutil.js @@ -13,6 +13,10 @@ export function elem(name = 'div', props = {}, children = []) { const el = document.createElement(name); Object.assign(el, props); - el.append(...children); + if (typeof children === 'string') { + el.append(children); + } else { + el.append(...children); + } return el; } From d5fa2f420b8d087a1325bb115c5068b207081271 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 12 Nov 2022 11:54:49 +0100 Subject: [PATCH 10/23] feed: add formatted absolute time Useful to see when a text note was written. --- src/main.css | 3 ++- src/main.js | 24 +++++++++++++----------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/main.css b/src/main.css index c90e3e9..dc8ae5a 100644 --- a/src/main.css +++ b/src/main.css @@ -58,7 +58,8 @@ body { line-height: 1.5; } -small { +small, +time { font-size: var(--font-small); } diff --git a/src/main.js b/src/main.js index 1a02c0d..e4fc8b7 100644 --- a/src/main.js +++ b/src/main.js @@ -11,10 +11,9 @@ pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); // pool.addRelay('wss://nostr.rocks', {read: true, write: false}); // pool.addRelay('wss://nostr-relay.wlvs.space', {read: true, write: false}); - const dateTime = new Intl.DateTimeFormat(navigator.language, { - dateStyle: 'full', - timeStyle: 'long', + dateStyle: 'short', + timeStyle: 'short', }); const userList = []; let max = 0; @@ -56,25 +55,28 @@ const subscription = pool.sub({ // '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // quark // 'a6057742e73ff93b89587c27a74edf2cdab86904291416e90dc98af1c5f70cfa', // mosc '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', // fiatjaf - pubkey, // me + '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38' // x1ddos + // pubkey, // me // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' // jb55 - ] + ], + limit: 100, } }); +// feed const hasEventTag = tag => tag[0] === 'e'; const getShortTagId = tag => `${tag[1].slice(0, 7)}${tag[2] ? '@' + tag[2] : ''}`; function renderTextNote(evt, relay) { - if (evt.tags.length) { - console.log('has tags', evt) - } const [host, img, time, userName] = getMetadata(evt, relay); const isReply = evt.tags.some(hasEventTag); - const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ + const body = elem('div', {className: 'mbox-body'}, [ renderProfile(userName, host), - elem('div', {}, isReply ? `reply to ${evt.tags.filter(hasEventTag).map(getShortTagId).join(' and ')}` : evt.id), - evt.content // text + elem('div', {}, [ + elem('small', {}, isReply ? `reply to ${evt.tags.filter(hasEventTag).map(getShortTagId).join(' and ')}` : evt.id), + elem('time', {dateTime: time, title: time}, ` on ${dateTime.format(time)}`), + ]), + evt.content, // text ]); rendernArticle([img, body], isReply && {className: 'mbox-reply'}); } From d2a7f4a603ae9482e8695acdbf10d8e6bfd6fdb2 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 13 Nov 2022 09:19:49 +0100 Subject: [PATCH 11/23] refactor: just moving helper functions around --- src/main.js | 53 +++++++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/src/main.js b/src/main.js index e4fc8b7..29fa6cc 100644 --- a/src/main.js +++ b/src/main.js @@ -15,7 +15,6 @@ const dateTime = new Intl.DateTimeFormat(navigator.language, { dateStyle: 'short', timeStyle: 'short', }); -const userList = []; let max = 0; function onEvent(evt, relay) { @@ -24,12 +23,7 @@ function onEvent(evt, relay) { } switch (evt.kind) { case 0: - try { - const content = JSON.parse(evt.content); - setMetadata(userList, relay, evt, content); - } catch(err) { - console.error(err); - } + handleMetadata(evt, relay); break; case 1: renderTextNote(evt, relay); @@ -104,25 +98,20 @@ function rendernArticle(content, props) { feedContainer.prepend(art); } -function getMetadata(evt, relay) { - const {host} = new URL(relay); - const user = userList.find(user => user.pubkey === evt.pubkey); - const userImg = /*user?.metadata[relay]?.picture || */'bubble.svg'; // enable pic once we have proxy - const userName = user?.metadata[relay]?.name || evt.pubkey.slice(0, 8); - const userAbout = user?.metadata[relay]?.about || ''; - const img = elem('img', { - className: 'mbox-img', - src: userImg, - alt: `${userName}@${host}`, - title: userAbout}, - ''); - const time = new Date(evt.created_at * 1000); - return [host, img, time, userName]; -} - +const userList = []; const tempContactList = {}; -function setMetadata(userList, relay, evt, content) { +function handleMetadata(evt, relay) { + try { + const content = JSON.parse(evt.content); + setMetadata(evt, relay, content); + } catch(err) { + console.log(evt); + console.error(err); + } +} + +function setMetadata(evt, relay, content) { const user = userList.find(u => u.pubkey === evt.pubkey); if (!user) { userList.push({ @@ -146,6 +135,22 @@ function setMetadata(userList, relay, evt, content) { } } +function getMetadata(evt, relay) { + const {host} = new URL(relay); + const user = userList.find(user => user.pubkey === evt.pubkey); + const userImg = /*user?.metadata[relay]?.picture || */'bubble.svg'; // enable pic once we have proxy + const userName = user?.metadata[relay]?.name || evt.pubkey.slice(0, 8); + const userAbout = user?.metadata[relay]?.about || ''; + const img = elem('img', { + className: 'mbox-img', + src: userImg, + alt: `${userName}@${host}`, + title: userAbout}, + ''); + const time = new Date(evt.created_at * 1000); + return [host, img, time, userName]; +} + function updateContactList(evt, relay) { const user = userList.find(u => u.pupkey === evt.pubkey); if (user) { From 549bfed7dda8d75d28582bf89ff92407de34a3fd Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 13 Nov 2022 13:15:56 +0100 Subject: [PATCH 12/23] feed: render textnotes from multiple relays in chronological order Events should be ordered by created_at. The randomly incoming events should be injected to their closest event sibling. Keeping a map of eventIds and dom nodes to reference and inject the new events. An alternative to keeping a map could be to add a data-attribute to each event and use querySelector. It is a trade-off os using more memory in order to not query the dom tree. --- src/main.js | 63 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/src/main.js b/src/main.js index 29fa6cc..89c837f 100644 --- a/src/main.js +++ b/src/main.js @@ -26,10 +26,10 @@ function onEvent(evt, relay) { handleMetadata(evt, relay); break; case 1: - renderTextNote(evt, relay); + handleTextNote(evt, relay); break; case 2: - renderRecommendServer(evt, relay); + feedContainer.prepend(renderRecommendServer(evt, relay)); break; case 3: updateContactList(evt, relay); @@ -57,8 +57,57 @@ const subscription = pool.sub({ } }); -// feed +const textNoteList = []; +const replyList = []; +const eventRelayMap = {}; const hasEventTag = tag => tag[0] === 'e'; + +let debounceRenderFeedTimer; +function handleTextNote(evt, relay) { + if (eventRelayMap[evt.id]) { + eventRelayMap[evt.id] = [relay, ...(eventRelayMap[evt.id])]; + } else { + eventRelayMap[evt.id] = [relay]; + (evt.tags.some(hasEventTag) ? replyList : textNoteList).push(evt); + clearTimeout(debounceRenderFeedTimer); + debounceRenderFeedTimer = setTimeout(renderFeed, 200); + } +} + +// feed +const feedContainer = document.querySelector('#feed'); +const feedDomMap = {}; + +let debounceDebugMessageTimer; +function renderFeed() { + const sortedFeeds = textNoteList.sort((evt1, evt2) => { + if (evt1.created_at === evt2.created_at) { + console.log('OMG exactly at the same time', evt1, evt2); + } + return evt1.created_at > evt2.created_at ? -1 : 1; + }); + + clearTimeout(debounceDebugMessageTimer); + debounceDebugMessageTimer = setTimeout(() => { + console.log(`${sortedFeeds.map(e => dateTime.format(e.created_at * 1000)).join('\n')}`) + }, 1000); + sortedFeeds.forEach((textNoteEvent, i) => { + if (feedDomMap[textNoteEvent.id]) { + // TODO check eventRelayMap if event was published to different relays + return; + } + const art = renderTextNote(textNoteEvent, eventRelayMap[textNoteEvent.id]); + feedDomMap[textNoteEvent.id] = art; + if (i === 0) { + feedContainer.prepend(art); + } else { + feedDomMap[sortedFeeds[i - 1].id].after(art); + // feedContainer.insertBefore(art, feedDomMap[sortedFeeds[i - 1].id]); + //feedDomMap[sortedFeeds[i - 1].id].after(art); + } + }); +} + const getShortTagId = tag => `${tag[1].slice(0, 7)}${tag[2] ? '@' + tag[2] : ''}`; function renderTextNote(evt, relay) { @@ -72,7 +121,7 @@ function renderTextNote(evt, relay) { ]), evt.content, // text ]); - rendernArticle([img, body], isReply && {className: 'mbox-reply'}); + return rendernArticle([img, body], isReply && {className: 'mbox-reply'}); } function renderRecommendServer(evt, relay) { @@ -81,7 +130,7 @@ function renderRecommendServer(evt, relay) { renderProfile(userName, host), `recommends server: ${evt.content}` ]); - rendernArticle([img, body], {className: 'mbox-recommend-server'}); + return rendernArticle([img, body], {className: 'mbox-recommend-server'}); } function renderProfile(userName, host) { @@ -90,12 +139,10 @@ function renderProfile(userName, host) { ]); } -const feedContainer = document.querySelector('#feed'); function rendernArticle(content, props) { const className = ['mbox', props?.className].join(' '); - const art = elem('article', {...props, className}, content); - feedContainer.prepend(art); + return elem('article', {...props, className}, content); } const userList = []; From 09bb691f6ce5695b06408854c6f8f732e276793c Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 13 Nov 2022 15:19:02 +0100 Subject: [PATCH 13/23] feed: refactoring and deterministic chronological feed order (maybe) chronological order: Getting there.. cleanup: removed debounce code for now keep also recommend server events in the dom map, not sure if just everything should be in the map. --- src/main.js | 83 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 30 deletions(-) diff --git a/src/main.js b/src/main.js index 89c837f..1b5927d 100644 --- a/src/main.js +++ b/src/main.js @@ -4,21 +4,21 @@ import {elem} from './domutil.js'; const pool = relayPool(); pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); // pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); -// pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); +pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); // pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); // pool.addRelay('wss://relay.damus.io', {read: true, write: true}); // read only // pool.addRelay('wss://nostr.rocks', {read: true, write: false}); // pool.addRelay('wss://nostr-relay.wlvs.space', {read: true, write: false}); -const dateTime = new Intl.DateTimeFormat(navigator.language, { +const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */, { dateStyle: 'short', - timeStyle: 'short', + timeStyle: 'medium', }); let max = 0; function onEvent(evt, relay) { - if (max++ >= 23) { + if (max++ >= 2123) { return subscription.unsub(); } switch (evt.kind) { @@ -29,7 +29,7 @@ function onEvent(evt, relay) { handleTextNote(evt, relay); break; case 2: - feedContainer.prepend(renderRecommendServer(evt, relay)); + handleRecommendServer(evt, relay); break; case 3: updateContactList(evt, relay); @@ -39,21 +39,20 @@ function onEvent(evt, relay) { } } -const pubkey = localStorage.getItem('pub_key') -console.log({pubkey}) +// const pubkey = localStorage.getItem('pub_key') const subscription = pool.sub({ cb: onEvent, filter: { - authors: [ - // '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // quark - // 'a6057742e73ff93b89587c27a74edf2cdab86904291416e90dc98af1c5f70cfa', // mosc - '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', // fiatjaf - '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38' // x1ddos - // pubkey, // me - // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' // jb55 - ], - limit: 100, + // authors: [ + // '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // quark + // 'a6057742e73ff93b89587c27a74edf2cdab86904291416e90dc98af1c5f70cfa', // mosc + // '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', // fiatjaf + // '52155da703585f25053830ac39863c80ea6adc57b360472c16c566a412d2bc38', // x1ddos + // // pubkey, // me + // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55 + // ], + limit: 2000, } }); @@ -62,35 +61,33 @@ const replyList = []; const eventRelayMap = {}; const hasEventTag = tag => tag[0] === 'e'; -let debounceRenderFeedTimer; function handleTextNote(evt, relay) { if (eventRelayMap[evt.id]) { eventRelayMap[evt.id] = [relay, ...(eventRelayMap[evt.id])]; } else { eventRelayMap[evt.id] = [relay]; (evt.tags.some(hasEventTag) ? replyList : textNoteList).push(evt); - clearTimeout(debounceRenderFeedTimer); - debounceRenderFeedTimer = setTimeout(renderFeed, 200); + renderFeed() } } // feed const feedContainer = document.querySelector('#feed'); const feedDomMap = {}; +const sortByCreatedAt = (evt1, evt2) => { + if (evt1.created_at === evt2.created_at) { + //console.log('OMG exactly at the same time', evt1, evt2); + } + return evt1.created_at > evt2.created_at ? -1 : 1; +}; let debounceDebugMessageTimer; function renderFeed() { - const sortedFeeds = textNoteList.sort((evt1, evt2) => { - if (evt1.created_at === evt2.created_at) { - console.log('OMG exactly at the same time', evt1, evt2); - } - return evt1.created_at > evt2.created_at ? -1 : 1; - }); - + const sortedFeeds = textNoteList.sort(sortByCreatedAt).reverse(); clearTimeout(debounceDebugMessageTimer); debounceDebugMessageTimer = setTimeout(() => { console.log(`${sortedFeeds.map(e => dateTime.format(e.created_at * 1000)).join('\n')}`) - }, 1000); + }, 2000); sortedFeeds.forEach((textNoteEvent, i) => { if (feedDomMap[textNoteEvent.id]) { // TODO check eventRelayMap if event was published to different relays @@ -99,9 +96,14 @@ function renderFeed() { const art = renderTextNote(textNoteEvent, eventRelayMap[textNoteEvent.id]); feedDomMap[textNoteEvent.id] = art; if (i === 0) { - feedContainer.prepend(art); + feedContainer.append(art); } else { - feedDomMap[sortedFeeds[i - 1].id].after(art); + feedDomMap[sortedFeeds[i - 1].id].before(art); + console.log( + dateTime.format(sortedFeeds[i + 1].created_at * 1000), + ' > ', + dateTime.format(textNoteEvent.created_at * 1000), + ) // feedContainer.insertBefore(art, feedDomMap[sortedFeeds[i - 1].id]); //feedDomMap[sortedFeeds[i - 1].id].after(art); } @@ -124,6 +126,28 @@ function renderTextNote(evt, relay) { return rendernArticle([img, body], isReply && {className: 'mbox-reply'}); } +function handleRecommendServer(evt, relay) { + if (feedDomMap[evt.id]) { + // TODO event might also be published to different relays + return; + } + const art = renderRecommendServer(evt, relay); + if (textNoteList.length < 2) { + console.log('prob does happen often') + feedContainer.append(art); + return; + } + const closestTextNotes = textNoteList.sort((evt1, evt2) => { + if (Math.abs(evt1.created_at - evt.created_at) < Math.abs(evt2.created_at - evt.created_at)) { + return -1; + } else { + return 1; + } + }); + feedDomMap[closestTextNotes[0].id].after(art); + feedDomMap[evt.id] = art; +} + function renderRecommendServer(evt, relay) { const [host, img, time, userName] = getMetadata(evt, relay); const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ @@ -139,7 +163,6 @@ function renderProfile(userName, host) { ]); } - function rendernArticle(content, props) { const className = ['mbox', props?.className].join(' '); return elem('article', {...props, className}, content); From b2ad1d4c607695066738f74b73a04dd6e25a11e3 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 13 Nov 2022 21:20:42 +0100 Subject: [PATCH 14/23] enable send Send is enabled now, but some errors do not surface, for exmaple if the network is down, tried catching those but will need to dig deeper into that. --- src/index.html | 3 ++- src/main.js | 48 +++++++++++++++++++++++++++++++++--------------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/index.html b/src/index.html index 7316539..556d615 100644 --- a/src/index.html +++ b/src/index.html @@ -17,8 +17,9 @@
- +
+
diff --git a/src/main.js b/src/main.js index 1b5927d..565a7d3 100644 --- a/src/main.js +++ b/src/main.js @@ -4,7 +4,7 @@ import {elem} from './domutil.js'; const pool = relayPool(); pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); // pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); -pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); +// pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); // pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); // pool.addRelay('wss://relay.damus.io', {read: true, write: true}); // read only @@ -35,7 +35,7 @@ function onEvent(evt, relay) { updateContactList(evt, relay); break; default: - console.log(`TODO: add support for event kind ${evt.kind}`, evt) + // console.log(`TODO: add support for event kind ${evt.kind}`, evt) } } @@ -100,7 +100,7 @@ function renderFeed() { } else { feedDomMap[sortedFeeds[i - 1].id].before(art); console.log( - dateTime.format(sortedFeeds[i + 1].created_at * 1000), + dateTime.format(sortedFeeds[i - 1].created_at * 1000), ' > ', dateTime.format(textNoteEvent.created_at * 1000), ) @@ -234,30 +234,48 @@ function updateContactList(evt, relay) { // check pool.status -// publish +// send +const sendStatus = document.querySelector('#sendstatus'); +const onSendError = err => { + sendStatus.textContent = err.message; + sendStatus.hidden = false; +}; const publish = document.querySelector('#publish'); publish.addEventListener('click', async () => { const pubkey = localStorage.getItem('pub_key'); const privatekey = localStorage.getItem('private_key'); if (!pubkey || !privatekey) { - return console.warn('no pubkey/privatekey'); + return onSendError(new Error('no pubkey/privatekey')); + } + if (!input.value) { + return onSendError(new Error('message is empty')); } const newEvent = { kind: 1, pubkey, - content: 'geil', + content: input.value, tags: [], created_at: Math.floor(Date.now() * 0.001), }; - const sig = await signEvent(newEvent, privatekey); - const ev = await pool.publish({...newEvent, sig}, (status, url) => { - if (status === 0) { - console.log(`publish request sent to ${url}`) - } - if (status === 1) { - console.log(`event published by ${url}`, ev) - } - }); + const sig = await signEvent(newEvent, privatekey).catch(onSendError); + if (sig) { + const ev = await pool.publish({...newEvent, sig}, (status, url) => { + if (status === 0) { + console.info(`publish request sent to ${url}`); + } + if (status === 1) { + sendStatus.hidden = true; + input.value = ''; + publish.disabled = true; + console.info(`event published by ${url}`, ev); + } + }); + } +}); + +const input = document.querySelector('input[name="message"]'); +input.addEventListener('input', () => { + publish.disabled = !input.value; }); // settings From a2a5cfa3418ef2d0da25d59893cad29f0e8e405c Mon Sep 17 00:00:00 2001 From: OFF0 Date: Mon, 14 Nov 2022 08:15:20 +0100 Subject: [PATCH 15/23] refactoring: eleanup part I Styled profile image to be aligned at the top together with the event metadata and content. Still need to decide on which relays to enable. JavaScript lint fixes. --- src/cards.css | 3 ++- src/main.js | 36 +++++++++++++----------------------- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/src/cards.css b/src/cards.css index 38db3b9..8fe12bc 100644 --- a/src/cards.css +++ b/src/cards.css @@ -12,10 +12,11 @@ } .mbox .mbox-img { - align-self: center; + align-self: start; flex-basis: 4ch; height: 4ch; margin-right: 1rem; + margin-top: .5ch; max-width: 4ch; } diff --git a/src/main.js b/src/main.js index 565a7d3..4e39804 100644 --- a/src/main.js +++ b/src/main.js @@ -3,7 +3,7 @@ import {elem} from './domutil.js'; // curl -H 'accept: application/nostr+json' https://nostr.x1ddos.ch const pool = relayPool(); pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); -// pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); +pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); // pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); // pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); // pool.addRelay('wss://relay.damus.io', {read: true, write: true}); @@ -35,7 +35,7 @@ function onEvent(evt, relay) { updateContactList(evt, relay); break; default: - // console.log(`TODO: add support for event kind ${evt.kind}`, evt) + console.log(`TODO: add support for event kind ${evt.kind}`, evt) } } @@ -67,7 +67,7 @@ function handleTextNote(evt, relay) { } else { eventRelayMap[evt.id] = [relay]; (evt.tags.some(hasEventTag) ? replyList : textNoteList).push(evt); - renderFeed() + renderFeed(); } } @@ -76,18 +76,18 @@ const feedContainer = document.querySelector('#feed'); const feedDomMap = {}; const sortByCreatedAt = (evt1, evt2) => { if (evt1.created_at === evt2.created_at) { - //console.log('OMG exactly at the same time', evt1, evt2); + // 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; }; -let debounceDebugMessageTimer; +// let debounceDebugMessageTimer; function renderFeed() { const sortedFeeds = textNoteList.sort(sortByCreatedAt).reverse(); - clearTimeout(debounceDebugMessageTimer); - debounceDebugMessageTimer = setTimeout(() => { - console.log(`${sortedFeeds.map(e => dateTime.format(e.created_at * 1000)).join('\n')}`) - }, 2000); + // clearTimeout(debounceDebugMessageTimer); + // debounceDebugMessageTimer = setTimeout(() => { + // console.log(`${sortedFeeds.map(e => dateTime.format(e.created_at * 1000)).join('\n')}`) + // }, 2000); sortedFeeds.forEach((textNoteEvent, i) => { if (feedDomMap[textNoteEvent.id]) { // TODO check eventRelayMap if event was published to different relays @@ -99,13 +99,6 @@ function renderFeed() { feedContainer.append(art); } else { feedDomMap[sortedFeeds[i - 1].id].before(art); - console.log( - dateTime.format(sortedFeeds[i - 1].created_at * 1000), - ' > ', - dateTime.format(textNoteEvent.created_at * 1000), - ) - // feedContainer.insertBefore(art, feedDomMap[sortedFeeds[i - 1].id]); - //feedDomMap[sortedFeeds[i - 1].id].after(art); } }); } @@ -133,7 +126,6 @@ function handleRecommendServer(evt, relay) { } const art = renderRecommendServer(evt, relay); if (textNoteList.length < 2) { - console.log('prob does happen often') feedContainer.append(art); return; } @@ -152,7 +144,7 @@ function renderRecommendServer(evt, relay) { const [host, img, time, userName] = getMetadata(evt, relay); const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ renderProfile(userName, host), - `recommends server: ${evt.content}` + `recommends server: ${evt.content}`, ]); return rendernArticle([img, body], {className: 'mbox-recommend-server'}); } @@ -208,7 +200,7 @@ function setMetadata(evt, relay, content) { function getMetadata(evt, relay) { const {host} = new URL(relay); const user = userList.find(user => user.pubkey === evt.pubkey); - const userImg = /*user?.metadata[relay]?.picture || */'bubble.svg'; // enable pic once we have proxy + const userImg = /*user?.metadata[relay]?.picture || */'bubble.svg'; // TODO: enable pic once we have proxy const userName = user?.metadata[relay]?.name || evt.pubkey.slice(0, 8); const userAbout = user?.metadata[relay]?.about || ''; const img = elem('img', { @@ -274,9 +266,7 @@ publish.addEventListener('click', async () => { }); const input = document.querySelector('input[name="message"]'); -input.addEventListener('input', () => { - publish.disabled = !input.value; -}); +input.addEventListener('input', () => publish.disabled = !input.value); // settings const form = document.querySelector('form[name="settings"]'); @@ -285,7 +275,7 @@ const pubKeyInput = form.querySelector('#pubkey'); const statusMessage = form.querySelector('#keystatus'); const generateBtn = form.querySelector('button[name="generate"]'); const importBtn = form.querySelector('button[name="import"]'); -const privateTgl = form.querySelector('button[name="privatekey-toggle"]') +const privateTgl = form.querySelector('button[name="privatekey-toggle"]'); generateBtn.addEventListener('click', () => { const privatekey = generatePrivateKey(); From 80a9e82f7902ee16a4d69be93a24b11e08103571 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Mon, 14 Nov 2022 19:37:49 +0100 Subject: [PATCH 16/23] refactor: name sort by created_at function --- src/main.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main.js b/src/main.js index 4e39804..f0e3887 100644 --- a/src/main.js +++ b/src/main.js @@ -104,6 +104,9 @@ function renderFeed() { } const getShortTagId = tag => `${tag[1].slice(0, 7)}${tag[2] ? '@' + tag[2] : ''}`; +const sortCreatedAt = ({created_at: a}, {created_at: b}) => ( + Math.abs(a - evt.created_at) < Math.abs(b - evt.created_at) ? -1 : 1 +); function renderTextNote(evt, relay) { const [host, img, time, userName] = getMetadata(evt, relay); @@ -129,13 +132,7 @@ function handleRecommendServer(evt, relay) { feedContainer.append(art); return; } - const closestTextNotes = textNoteList.sort((evt1, evt2) => { - if (Math.abs(evt1.created_at - evt.created_at) < Math.abs(evt2.created_at - evt.created_at)) { - return -1; - } else { - return 1; - } - }); + const closestTextNotes = textNoteList.sort(sortCreatedAt); feedDomMap[closestTextNotes[0].id].after(art); feedDomMap[evt.id] = art; } From 397df6d5a4fb3399ea33d1aa3ef82bdc76000538 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Mon, 14 Nov 2022 23:01:30 +0100 Subject: [PATCH 17/23] feed: show replies Render known replies, also added debounce debug info to test chronological order and keep an eye on. --- src/cards.css | 4 -- src/main.js | 107 ++++++++++++++++++++++++++++++++------------------ 2 files changed, 68 insertions(+), 43 deletions(-) diff --git a/src/cards.css b/src/cards.css index 8fe12bc..5c1ef9c 100644 --- a/src/cards.css +++ b/src/cards.css @@ -7,10 +7,6 @@ margin-bottom: 1rem; } -.mbox-reply { - padding-left: 4ch; -} - .mbox .mbox-img { align-self: start; flex-basis: 4ch; diff --git a/src/main.js b/src/main.js index f0e3887..0072be1 100644 --- a/src/main.js +++ b/src/main.js @@ -3,9 +3,9 @@ import {elem} from './domutil.js'; // curl -H 'accept: application/nostr+json' https://nostr.x1ddos.ch const pool = relayPool(); pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); -pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); -// pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); -// pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); +// pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); +pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); +pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); // pool.addRelay('wss://relay.damus.io', {read: true, write: true}); // read only // pool.addRelay('wss://nostr.rocks', {read: true, write: false}); @@ -18,7 +18,7 @@ const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */, { let max = 0; function onEvent(evt, relay) { - if (max++ >= 2123) { + if (max++ >= 223) { return subscription.unsub(); } switch (evt.kind) { @@ -35,7 +35,7 @@ function onEvent(evt, relay) { updateContactList(evt, relay); break; default: - console.log(`TODO: add support for event kind ${evt.kind}`, evt) + // console.log(`TODO: add support for event kind ${evt.kind}`/*, evt*/) } } @@ -52,7 +52,7 @@ const subscription = pool.sub({ // // pubkey, // me // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55 // ], - limit: 2000, + limit: 200, } }); @@ -66,7 +66,14 @@ function handleTextNote(evt, relay) { eventRelayMap[evt.id] = [relay, ...(eventRelayMap[evt.id])]; } else { eventRelayMap[evt.id] = [relay]; - (evt.tags.some(hasEventTag) ? replyList : textNoteList).push(evt); + if (evt.tags.some(hasEventTag)) { + replyList.push(evt) + if (feedDomMap[evt.tags[0][1]]) { + console.log('CALL ME', evt.tags[0][1], feedDomMap[evt.tags[0][1]]); + } + } else { + textNoteList.push(evt); + } renderFeed(); } } @@ -81,45 +88,57 @@ const sortByCreatedAt = (evt1, evt2) => { return evt1.created_at > evt2.created_at ? -1 : 1; }; -// let debounceDebugMessageTimer; +let debounceDebugMessageTimer; function renderFeed() { const sortedFeeds = textNoteList.sort(sortByCreatedAt).reverse(); - // clearTimeout(debounceDebugMessageTimer); - // debounceDebugMessageTimer = setTimeout(() => { - // console.log(`${sortedFeeds.map(e => dateTime.format(e.created_at * 1000)).join('\n')}`) - // }, 2000); + // debug + clearTimeout(debounceDebugMessageTimer); + debounceDebugMessageTimer = setTimeout(() => { + console.log(`${sortedFeeds.reverse().map(e => dateTime.format(e.created_at * 1000)).join('\n')}`) + }, 2000); sortedFeeds.forEach((textNoteEvent, i) => { if (feedDomMap[textNoteEvent.id]) { // TODO check eventRelayMap if event was published to different relays return; } - const art = renderTextNote(textNoteEvent, eventRelayMap[textNoteEvent.id]); - feedDomMap[textNoteEvent.id] = art; + const article = createTextNote(textNoteEvent, eventRelayMap[textNoteEvent.id]); + feedDomMap[textNoteEvent.id] = article; if (i === 0) { - feedContainer.append(art); + feedContainer.append(article); } else { - feedDomMap[sortedFeeds[i - 1].id].before(art); + feedDomMap[sortedFeeds[i - 1].id].before(article); } }); } -const getShortTagId = tag => `${tag[1].slice(0, 7)}${tag[2] ? '@' + tag[2] : ''}`; -const sortCreatedAt = ({created_at: a}, {created_at: b}) => ( +const sortEventCreatedAt = (evt) => ( + {created_at: a}, + {created_at: b}, +) => ( Math.abs(a - evt.created_at) < Math.abs(b - evt.created_at) ? -1 : 1 ); -function renderTextNote(evt, relay) { - const [host, img, time, userName] = getMetadata(evt, relay); - const isReply = evt.tags.some(hasEventTag); +function createTextNote(evt, relay) { + const {host, img, isReply, replies, time, userName} = getMetadata(evt, relay); + const headerInfo = isReply ? [ + elem('strong', {title: evt.pubkey}, userName) + ] : [ + elem('strong', {title: evt.pubkey}, userName), + elem('span', { + title: `Event ${evt.id} + ${isReply ? `\nReply ${evt.tags[0][1]}\n` : ''}\n${time}` + }, ` on ${host} `), + ]; const body = elem('div', {className: 'mbox-body'}, [ - renderProfile(userName, host), - elem('div', {}, [ - elem('small', {}, isReply ? `reply to ${evt.tags.filter(hasEventTag).map(getShortTagId).join(' and ')}` : evt.id), - elem('time', {dateTime: time, title: time}, ` on ${dateTime.format(time)}`), + elem('header', { + className: 'mbox-header', + }, [ + elem('small', {}, headerInfo), ]), evt.content, // text + replies[0] ? elem('div', {className: 'mobx-replies'}, replies.map(e => createTextNote(e, relay))) : '', ]); - return rendernArticle([img, body], isReply && {className: 'mbox-reply'}); + return rendernArticle([img, body]); } function handleRecommendServer(evt, relay) { @@ -132,26 +151,25 @@ function handleRecommendServer(evt, relay) { feedContainer.append(art); return; } - const closestTextNotes = textNoteList.sort(sortCreatedAt); + const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt)); feedDomMap[closestTextNotes[0].id].after(art); feedDomMap[evt.id] = art; } function renderRecommendServer(evt, relay) { - const [host, img, time, userName] = getMetadata(evt, relay); + const {host, img, time, userName} = getMetadata(evt, relay); const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ - renderProfile(userName, host), + elem('header', {className: 'mbox-header'}, [ + elem('small', {}, [ + elem('strong', {}, userName), + ` on ${host} `, + ]), + ]), `recommends server: ${evt.content}`, ]); return rendernArticle([img, body], {className: 'mbox-recommend-server'}); } -function renderProfile(userName, host) { - return elem('header', {className: 'mbox-header'}, [ - elem('small', {}, [elem('strong', {}, userName), ` on ${host} `]), - ]); -} - function rendernArticle(content, props) { const className = ['mbox', props?.className].join(' '); return elem('article', {...props, className}, content); @@ -189,25 +207,36 @@ function setMetadata(evt, relay, content) { if (tempContactList[relay]) { const updates = tempContactList[relay].filter(update => update.pubkey === evt.pubkey); if (updates) { - console.log('TODO: add contact list (kind 3)', updates); + // console.log('TODO: add contact list (kind 3)', updates); } } } +const getHost = (url) => { + try { + return new URL(url).host; + } catch(e) { + return false; + } +} + function getMetadata(evt, relay) { - const {host} = new URL(relay); + const host = getHost(relay[0]); const user = userList.find(user => user.pubkey === evt.pubkey); const userImg = /*user?.metadata[relay]?.picture || */'bubble.svg'; // TODO: enable pic once we have proxy const userName = user?.metadata[relay]?.name || evt.pubkey.slice(0, 8); const userAbout = user?.metadata[relay]?.about || ''; + const isReply = evt.tags.some(hasEventTag); const img = elem('img', { className: 'mbox-img', src: userImg, alt: `${userName}@${host}`, title: userAbout}, - ''); + '' + ); + const replies = replyList.filter((reply) => reply.tags[0][1] === evt.id); const time = new Date(evt.created_at * 1000); - return [host, img, time, userName]; + return {host, img, isReply, replies, time, userName}; } function updateContactList(evt, relay) { From 2d8e60a6df3f00ed231c31f1a2bbb85296aa2486 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Tue, 15 Nov 2022 09:05:53 +0100 Subject: [PATCH 18/23] feed: support reply and render incoming replies Keeping current reply info so it can be accessed later to publish the reply. Before only known replies were rendered, now incoming replies get added to the existing text note instantly. This needs to create a reply container if this is the first reply of this event. Added time ago formatting and a helper function that switches between relative time (if event < 24h) or absolute formatted time (if older than 1 day). --- src/cards.css | 6 +- src/domutil.js | 5 +- src/form.css | 3 +- src/index.html | 4 +- src/main.css | 2 +- src/main.js | 144 ++++++++++++++++++++++++++++++++---------------- src/tabs.css | 2 +- src/timeutil.js | 65 ++++++++++++++++++++++ 8 files changed, 175 insertions(+), 56 deletions(-) create mode 100644 src/timeutil.js diff --git a/src/cards.css b/src/cards.css index 5c1ef9c..b90ca20 100644 --- a/src/cards.css +++ b/src/cards.css @@ -17,11 +17,11 @@ } .mbox-body { - color: var(--color-accent); flex-basis: calc(100% - 64px - 1rem); flex-grow: 0; flex-shrink: 1; max-width: 96ch; + word-break: break-word; } .mbox-header { @@ -30,6 +30,10 @@ flex-shrink: 1; margin-top: 0; } +.mbox-header time, +.mbox-username { + color: var(--color-accent); +} .mbox-recommend-server .mbox-body { display: block; diff --git a/src/domutil.js b/src/domutil.js index a3ac911..2a0f656 100644 --- a/src/domutil.js +++ b/src/domutil.js @@ -10,7 +10,7 @@ * @param {Array} children * @return HTMLElement */ -export function elem(name = 'div', props = {}, children = []) { +export function elem(name = 'div', {data, ...props} = {}, children = []) { const el = document.createElement(name); Object.assign(el, props); if (typeof children === 'string') { @@ -18,5 +18,8 @@ export function elem(name = 'div', props = {}, children = []) { } else { el.append(...children); } + if (data) { + Object.entries(data).forEach(([key, value]) => el.dataset[key] = value); + } return el; } diff --git a/src/form.css b/src/form.css index 508e06f..605a141 100644 --- a/src/form.css +++ b/src/form.css @@ -21,7 +21,7 @@ label { } label { - color: var(--bgcolor-accent); + color: var(--color-accent); } input[type="password"], @@ -52,6 +52,7 @@ button { border-radius: .2rem; cursor: pointer; outline-offset: 1px; + word-break: normal; } button:focus { diff --git a/src/index.html b/src/index.html index 556d615..8664a66 100644 --- a/src/index.html +++ b/src/index.html @@ -14,8 +14,8 @@
-
-
+
+ diff --git a/src/main.css b/src/main.css index dc8ae5a..e77ab5d 100644 --- a/src/main.css +++ b/src/main.css @@ -21,7 +21,7 @@ --bgcolor-accent: #ff731d; --bgcolor-inactive: #bababa; --color: rgb(68 68 68); - --color-accent: rgb(0 0 0); + --color-accent: #ff731d; --bgcolor-danger: rgb(255 0 0); } } diff --git a/src/main.js b/src/main.js index 0072be1..028b71b 100644 --- a/src/main.js +++ b/src/main.js @@ -1,26 +1,23 @@ import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; import {elem} from './domutil.js'; +import {dateTime, formatTime} from './timeutil.js'; // curl -H 'accept: application/nostr+json' https://nostr.x1ddos.ch const pool = relayPool(); -pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); -// pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); -pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); // pool.addRelay('wss://relay.damus.io', {read: true, write: true}); +pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); +// pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); +// pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); // read only // pool.addRelay('wss://nostr.rocks', {read: true, write: false}); -// pool.addRelay('wss://nostr-relay.wlvs.space', {read: true, write: false}); -const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */, { - dateStyle: 'short', - timeStyle: 'medium', -}); + let max = 0; function onEvent(evt, relay) { - if (max++ >= 223) { - return subscription.unsub(); - } + // if (max++ >= 223) { + // return subscription.unsub(); + // } switch (evt.kind) { case 0: handleMetadata(evt, relay); @@ -52,7 +49,8 @@ const subscription = pool.sub({ // // pubkey, // me // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55 // ], - limit: 200, + // since: new Date(Date.now() - (24 * 60 * 60 * 1000)), + limit: 2000, } }); @@ -67,10 +65,8 @@ function handleTextNote(evt, relay) { } else { eventRelayMap[evt.id] = [relay]; if (evt.tags.some(hasEventTag)) { - replyList.push(evt) - if (feedDomMap[evt.tags[0][1]]) { - console.log('CALL ME', evt.tags[0][1], feedDomMap[evt.tags[0][1]]); - } + replyList.push(evt); + handleReply(evt, relay); } else { textNoteList.push(evt); } @@ -88,62 +84,88 @@ const sortByCreatedAt = (evt1, evt2) => { return evt1.created_at > evt2.created_at ? -1 : 1; }; -let debounceDebugMessageTimer; +// let debounceDebugMessageTimer; function renderFeed() { const sortedFeeds = textNoteList.sort(sortByCreatedAt).reverse(); // debug - clearTimeout(debounceDebugMessageTimer); - debounceDebugMessageTimer = setTimeout(() => { - console.log(`${sortedFeeds.reverse().map(e => dateTime.format(e.created_at * 1000)).join('\n')}`) - }, 2000); + // clearTimeout(debounceDebugMessageTimer); + // debounceDebugMessageTimer = setTimeout(() => { + // console.log(`${sortedFeeds.reverse().map(e => dateTime.format(e.created_at * 1000)).join('\n')}`) + // }, 2000); sortedFeeds.forEach((textNoteEvent, i) => { if (feedDomMap[textNoteEvent.id]) { // TODO check eventRelayMap if event was published to different relays return; } const article = createTextNote(textNoteEvent, eventRelayMap[textNoteEvent.id]); - feedDomMap[textNoteEvent.id] = article; if (i === 0) { feedContainer.append(article); } else { feedDomMap[sortedFeeds[i - 1].id].before(article); } + feedDomMap[textNoteEvent.id] = article; }); } -const sortEventCreatedAt = (evt) => ( - {created_at: a}, - {created_at: b}, -) => ( - Math.abs(a - evt.created_at) < Math.abs(b - evt.created_at) ? -1 : 1 -); +setInterval(() => { + document.querySelectorAll('time[datetime]').forEach(timeElem => { + timeElem.textContent = formatTime(new Date(timeElem.dateTime)); + }); +}, 10000); function createTextNote(evt, relay) { const {host, img, isReply, replies, time, userName} = getMetadata(evt, relay); + const name = elem('strong', {className: 'mbox-username', title: evt.pubkey}, userName); + const timeElem = elem('time', { dateTime: time.toISOString()}, formatTime(time)); const headerInfo = isReply ? [ - elem('strong', {title: evt.pubkey}, userName) + name, ' ', timeElem ] : [ - elem('strong', {title: evt.pubkey}, userName), + name, elem('span', { title: `Event ${evt.id} - ${isReply ? `\nReply ${evt.tags[0][1]}\n` : ''}\n${time}` + ${isReply ? `\nReply ${evt.tags[0][1]}\n` : ''}` }, ` on ${host} `), + timeElem, ]; const body = elem('div', {className: 'mbox-body'}, [ - elem('header', { - className: 'mbox-header', - }, [ + elem('header', {className: 'mbox-header'}, [ elem('small', {}, headerInfo), ]), evt.content, // text + elem('br'), + elem('button', { + className: 'button-inline', + name: 'reply', type: 'button', + data: {'eventId': evt.id, relay} + }, [ + elem('small', {}, 'reply') + ]), replies[0] ? elem('div', {className: 'mobx-replies'}, replies.map(e => createTextNote(e, relay))) : '', ]); return rendernArticle([img, body]); } +function handleReply(evt, relay) { + const article = feedDomMap[evt.tags[0][1]]; + if (article) { + let replyContainer = article.querySelector('.mobx-replies'); + if (!replyContainer) { + replyContainer = elem('div', {className: 'mobx-replies'}); + article.querySelector('.mbox-body').append(replyContainer); + } + replyContainer.append(createTextNote(evt, relay)) + } +} + +const sortEventCreatedAt = (created_at) => ( + {created_at: a}, + {created_at: b}, +) => ( + Math.abs(a - created_at) < Math.abs(b - created_at) ? -1 : 1 +); + function handleRecommendServer(evt, relay) { if (feedDomMap[evt.id]) { - // TODO event might also be published to different relays return; } const art = renderRecommendServer(evt, relay); @@ -151,21 +173,20 @@ function handleRecommendServer(evt, relay) { feedContainer.append(art); return; } - const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt)); + const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); feedDomMap[closestTextNotes[0].id].after(art); feedDomMap[evt.id] = art; } function renderRecommendServer(evt, relay) { - const {host, img, time, userName} = getMetadata(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('strong', {}, userName), - ` on ${host} `, + elem('strong', {}, userName) ]), ]), - `recommends server: ${evt.content}`, + ` recommends server: ${evt.content}`, ]); return rendernArticle([img, body], {className: 'mbox-recommend-server'}); } @@ -215,13 +236,13 @@ function setMetadata(evt, relay, content) { const getHost = (url) => { try { return new URL(url).host; - } catch(e) { - return false; + } catch(err) { + return err; } } function getMetadata(evt, relay) { - const host = getHost(relay[0]); + const host = getHost(relay); const user = userList.find(user => user.pubkey === evt.pubkey); const userImg = /*user?.metadata[relay]?.picture || */'bubble.svg'; // TODO: enable pic once we have proxy const userName = user?.metadata[relay]?.name || evt.pubkey.slice(0, 8); @@ -231,9 +252,8 @@ function getMetadata(evt, relay) { className: 'mbox-img', src: userImg, alt: `${userName}@${host}`, - title: userAbout}, - '' - ); + title: userAbout, + }, ''); const replies = replyList.filter((reply) => reply.tags[0][1] === evt.id); const time = new Date(evt.created_at * 1000); return {host, img, isReply, replies, time, userName}; @@ -252,6 +272,26 @@ function updateContactList(evt, relay) { // check pool.status +// reply +const writeForm = document.querySelector('#writeForm'); +const input = document.querySelector('input[name="message"]'); +let lastReplyBtn = null; +let replyTo = null; +feedContainer.addEventListener('click', (e) => { + const button = e.target.closest('button'); + if (button && button.name === 'reply') { + if (lastReplyBtn) { + lastReplyBtn.hidden = false; + } + lastReplyBtn = button; + button.hidden = true; + button.after(writeForm); + writeForm.hidden = false; + replyTo = ['e', button.dataset.eventId, button.dataset.relay]; + input.focus(); + } +}); + // send const sendStatus = document.querySelector('#sendstatus'); const onSendError = err => { @@ -268,11 +308,12 @@ publish.addEventListener('click', async () => { if (!input.value) { return onSendError(new Error('message is empty')); } + const tags = replyTo ? [replyTo] : []; const newEvent = { kind: 1, pubkey, content: input.value, - tags: [], + tags, created_at: Math.floor(Date.now() * 0.001), }; const sig = await signEvent(newEvent, privatekey).catch(onSendError); @@ -285,13 +326,18 @@ publish.addEventListener('click', async () => { sendStatus.hidden = true; input.value = ''; publish.disabled = true; - console.info(`event published by ${url}`, ev); + if (lastReplyBtn) { + lastReplyBtn.hidden = false; + lastReplyBtn = null; + replyTo = null; + document.querySelector('#newMessage').append(writeForm); + } + // console.info(`event published by ${url}`, ev); } }); } }); -const input = document.querySelector('input[name="message"]'); input.addEventListener('input', () => publish.disabled = !input.value); // settings diff --git a/src/tabs.css b/src/tabs.css index 616d486..4942a37 100644 --- a/src/tabs.css +++ b/src/tabs.css @@ -34,7 +34,7 @@ .tab [type=radio]:checked ~ label { background-color: var(--bgcolor-accent); - color: var(--color-accent); + color: var(--color); z-index: 2; } diff --git a/src/timeutil.js b/src/timeutil.js new file mode 100644 index 0000000..810a4f9 --- /dev/null +++ b/src/timeutil.js @@ -0,0 +1,65 @@ +/** + * Intl.DateTimeFormat object + * + * example: + * + * console.log(dateTime.format(new Date())); + */ +export const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */, { + dateStyle: 'medium', + timeStyle: 'short', +}); + +/** + * format time relative to now, such as 5min ago + * + * @param {Date} time + * @param {string} locale + * @returns string + * + * example: + * + * console.log(timeAgo(new Date(Date.now() - 10000))); + * + */ +const timeAgo = (time, locale = 'en') => { + const relativeTime = new Intl.RelativeTimeFormat(locale, { + numeric: 'auto', + style: 'long', + }); + const timeSince = (Date.now() - time.getTime()) * 0.001; + const minutes = Math.floor(timeSince / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(months / 12); + + if (years > 0) { + return relativeTime.format(0 - years, 'year'); + } else if (months > 0) { + return relativeTime.format(0 - months, 'month'); + } else if (days > 0) { + return relativeTime.format(0 - days, 'day'); + } else if (hours > 0) { + return relativeTime.format(0 - hours, 'hour'); + } else if (minutes > 0) { + return relativeTime.format(0 - minutes, 'minute'); + } else { + return relativeTime.format(0 - timeSince, 'second'); + } +}; + +/** + * formatTime shows relative time if it is less than 24h else absolute datetime + * + * @param {time} date object to format + * @return string + */ +export const formatTime = (time) => { + const yesterday = new Date(Date.now() - (24 * 60 * 60 * 1000)); + if (time > yesterday) { + return timeAgo(time); + } else { + return dateTime.format(time); + }; +}; From c896789ccd7e168ea62048ac298b44a4d16dc61f Mon Sep 17 00:00:00 2001 From: OFF0 Date: Thu, 17 Nov 2022 23:18:53 +0100 Subject: [PATCH 19/23] settings: prevent accidental form data submitting in html mode If JavaScript is disabled the form in the settings should not leak private or pubkey data. --- src/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.html b/src/index.html index 8664a66..b939838 100644 --- a/src/index.html +++ b/src/index.html @@ -53,16 +53,16 @@
-
+ - + - +
From a71de213021ae7d37b4faad6492d7e0b3e64a864 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Fri, 18 Nov 2022 23:28:28 +0100 Subject: [PATCH 20/23] feed: playing with ui - shorten text notes after 280 chars - add expand button to shortened text - play around with profile image and username layout --- src/cards.css | 14 ++++++++++---- src/main.js | 47 ++++++++++++++++++++++++++++------------------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/src/cards.css b/src/cards.css index b90ca20..39fce5d 100644 --- a/src/cards.css +++ b/src/cards.css @@ -7,13 +7,19 @@ margin-bottom: 1rem; } -.mbox .mbox-img { +.mbox-img { + --size: 4ch; align-self: start; - flex-basis: 4ch; - height: 4ch; + flex-basis: var(--size); + height: var(--size); margin-right: 1rem; margin-top: .5ch; - max-width: 4ch; + max-width: var(--size); +} +.mbox-recommend-server .mbox-img { + --size: 2.5ch; + margin-left: 1ch; + margin-right: 1.5ch; } .mbox-body { diff --git a/src/main.js b/src/main.js index 028b71b..7226b4a 100644 --- a/src/main.js +++ b/src/main.js @@ -50,7 +50,7 @@ const subscription = pool.sub({ // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55 // ], // since: new Date(Date.now() - (24 * 60 * 60 * 1000)), - limit: 2000, + limit: 100, } }); @@ -116,23 +116,21 @@ setInterval(() => { function createTextNote(evt, relay) { const {host, img, isReply, replies, time, userName} = getMetadata(evt, relay); const name = elem('strong', {className: 'mbox-username', title: evt.pubkey}, userName); - const timeElem = elem('time', { dateTime: time.toISOString()}, formatTime(time)); - const headerInfo = isReply ? [ - name, ' ', timeElem - ] : [ - name, - elem('span', { - title: `Event ${evt.id} - ${isReply ? `\nReply ${evt.tags[0][1]}\n` : ''}` - }, ` on ${host} `), - timeElem, - ]; + const timeElem = elem('time', { dateTime: time.toISOString()}, formatTime(time)); + const hasLongContent = evt.content.length > 280; + const headerInfo = isReply + ? [name, ' ', timeElem] + : [name, ` on ${host} `, timeElem]; + const content = hasLongContent ? `${evt.content.slice(0, 280)}…` : evt.content; const body = elem('div', {className: 'mbox-body'}, [ - elem('header', {className: 'mbox-header'}, [ + elem('header', { + className: 'mbox-header', + title: `Event ${evt.id}\non ${host} ${time} + ${isReply ? `\nReply ${evt.tags[0][1]}\n` : ''}` + }, [ elem('small', {}, headerInfo), ]), - evt.content, // text - elem('br'), + elem('div', {data: hasLongContent ? {append: evt.content.slice(280)} : null}, content), elem('button', { className: 'button-inline', name: 'reply', type: 'button', @@ -153,7 +151,7 @@ function handleReply(evt, relay) { replyContainer = elem('div', {className: 'mobx-replies'}); article.querySelector('.mbox-body').append(replyContainer); } - replyContainer.append(createTextNote(evt, relay)) + replyContainer.append(createTextNote(evt, relay)); } } @@ -247,13 +245,14 @@ function getMetadata(evt, relay) { const userImg = /*user?.metadata[relay]?.picture || */'bubble.svg'; // TODO: enable pic once we have proxy const userName = user?.metadata[relay]?.name || evt.pubkey.slice(0, 8); const userAbout = user?.metadata[relay]?.about || ''; - const isReply = evt.tags.some(hasEventTag); + const title = `${userName} on ${host} ${userAbout}`; const img = elem('img', { className: 'mbox-img', src: userImg, - alt: `${userName}@${host}`, - title: userAbout, + alt: title, + title, }, ''); + const isReply = evt.tags.some(hasEventTag); const replies = replyList.filter((reply) => reply.tags[0][1] === evt.id); const time = new Date(evt.created_at * 1000); return {host, img, isReply, replies, time, userName}; @@ -289,6 +288,7 @@ feedContainer.addEventListener('click', (e) => { writeForm.hidden = false; replyTo = ['e', button.dataset.eventId, button.dataset.relay]; input.focus(); + return; } }); @@ -399,3 +399,12 @@ privateTgl.addEventListener('click', () => { privateKeyInput.value = localStorage.getItem('private_key'); pubKeyInput.value = localStorage.getItem('pub_key'); + +document.body.addEventListener('click', (e) => { + const append = e.target.closest('[data-append]'); + if (append) { + append.textContent += append.dataset.append; + delete append.dataset.append; + return; + } +}); \ No newline at end of file From 13b3db4302d021b4a1e661941b91469a1efcec11 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 19 Nov 2022 19:27:33 +0100 Subject: [PATCH 21/23] layout: update navigation and add icons Used different CSS-only tab technique. The implementation before messed up the whole height of the page and used unnecessary absolute positioning. Added comment icon from MFG Labs iconset (SIL) - https://github.com/MfgLabs/mfglabs-iconset Added outlined and filled heart from Elusiv icons (SIL) - https://github.com/dovy/elusive-iconfont Time ago seconds should be rounded. --- esbuildconf.js | 4 +- src/{ => assets}/bubble.svg | 0 src/assets/comment.svg | 1 + src/assets/heart-fill.svg | 1 + src/assets/heart.svg | 1 + src/cards.css | 4 +- src/domutil.js | 2 +- src/form.css | 20 +++++--- src/index.html | 92 +++++++++++++++++-------------------- src/main.css | 32 +++++++++++-- src/main.js | 44 +++++++++--------- src/tabs.css | 73 +++++++++++++++-------------- src/timeutil.js | 2 +- 13 files changed, 155 insertions(+), 121 deletions(-) rename src/{ => assets}/bubble.svg (100%) create mode 100644 src/assets/comment.svg create mode 100644 src/assets/heart-fill.svg create mode 100644 src/assets/heart.svg diff --git a/esbuildconf.js b/esbuildconf.js index 0988ebd..4428cd9 100644 --- a/esbuildconf.js +++ b/esbuildconf.js @@ -9,7 +9,9 @@ export const options = { 'src/main.js', 'src/main.css', 'src/index.html', - 'src/bubble.svg' + 'src/assets/bubble.svg', + 'src/assets/comment.svg', + 'src/assets/heart-fill.svg', ], outdir: 'dist', //entryNames: '[name]-[hash]', TODO: replace urls in index.html with hashed paths diff --git a/src/bubble.svg b/src/assets/bubble.svg similarity index 100% rename from src/bubble.svg rename to src/assets/bubble.svg diff --git a/src/assets/comment.svg b/src/assets/comment.svg new file mode 100644 index 0000000..1936a6d --- /dev/null +++ b/src/assets/comment.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/heart-fill.svg b/src/assets/heart-fill.svg new file mode 100644 index 0000000..2c1f312 --- /dev/null +++ b/src/assets/heart-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/heart.svg b/src/assets/heart.svg new file mode 100644 index 0000000..b142374 --- /dev/null +++ b/src/assets/heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/cards.css b/src/cards.css index 39fce5d..faa77c2 100644 --- a/src/cards.css +++ b/src/cards.css @@ -8,13 +8,14 @@ } .mbox-img { - --size: 4ch; + --size: 5rem; align-self: start; flex-basis: var(--size); height: var(--size); margin-right: 1rem; margin-top: .5ch; max-width: var(--size); + max-width: var(--size); } .mbox-recommend-server .mbox-img { --size: 2.5ch; @@ -38,7 +39,6 @@ } .mbox-header time, .mbox-username { - color: var(--color-accent); } .mbox-recommend-server .mbox-body { diff --git a/src/domutil.js b/src/domutil.js index 2a0f656..8c780a5 100644 --- a/src/domutil.js +++ b/src/domutil.js @@ -13,7 +13,7 @@ export function elem(name = 'div', {data, ...props} = {}, children = []) { const el = document.createElement(name); Object.assign(el, props); - if (typeof children === 'string') { + if (['number', 'string'].includes(typeof children)) { el.append(children); } else { el.append(...children); diff --git a/src/form.css b/src/form.css index 605a141..36324e6 100644 --- a/src/form.css +++ b/src/form.css @@ -4,7 +4,6 @@ form { input, textarea { color: var(--color); - font-family: monospace; font-size: 1.6rem; margin-bottom: 1.2rem; padding: 1.3rem 1.8rem; @@ -12,8 +11,8 @@ textarea { button, label { + cursor: pointer; display: block; - font-family: monospace; font-size: 1.6rem; margin-bottom: 0; padding: 1.3rem 1.8rem; @@ -34,8 +33,8 @@ input[type="text"] { } input[type="password"]:focus, input[type="text"]:focus { - border-color: #d4d4d4; - outline-offset: 1px; + border-color: var(--focus-border-color); + outline-offset: 2px; } .buttons { @@ -58,11 +57,18 @@ button { button:focus { } -.button-inline { +.btn-inline { + align-items: center; background: transparent; color: var(--color); - display: inline; - padding: .3rem; + display: inline-flex; + gap: .5ch; + line-height: 1; + padding: .6rem; +} +.btn-inline img { + max-height: 18px; + max-width: 18px; } button:disabled { diff --git a/src/index.html b/src/index.html index b939838..8b81010 100644 --- a/src/index.html +++ b/src/index.html @@ -6,14 +6,22 @@ -
- -
- - -
+
+ + + + + + + + + + + +
+
- +
@@ -22,57 +30,43 @@
-
+
-
- -
- - - - -
- - - - -
- - -
+ + + +
+ + + + + +
+ + + + +
+ +
+
-
- - -
- - - - -
- - - - -
-
-
- -
diff --git a/src/main.css b/src/main.css index e77ab5d..7052d73 100644 --- a/src/main.css +++ b/src/main.css @@ -3,6 +3,13 @@ @import "form.css"; :root { + /* 5px auto Highlight */ + --focus-border-color: rgb(0, 122, 255); + --focus-border-radius: 2px; + --focus-outline-color: rgb(127, 189, 247); + --focus-outline-style: solid; + --focus-outline-width: 2px; + --focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color); --font-small: 1.2rem; } @@ -53,20 +60,35 @@ html { body { background-color: var(--bgcolor); color: var(--color); - font-family: monospace; font-size: 1.6rem; line-height: 1.5; } +body, +button, +input, +select, +textarea { + font-family: monospace; +} + small, time { font-size: var(--font-small); } -*, ::after, ::before { - box-sizing: border-box; -} - .danger { background-color: var(--bgcolor-danger); } + +a:focus { + border-radius: var(--focus-border-radius); + outline: var(--focus-outline); + outline-offset: 0; +} + +img[alt] { + font-size: .9rem; + text-align: center; + word-break: break-all; +} diff --git a/src/main.js b/src/main.js index 7226b4a..9863ecb 100644 --- a/src/main.js +++ b/src/main.js @@ -75,7 +75,7 @@ function handleTextNote(evt, relay) { } // feed -const feedContainer = document.querySelector('#feed'); +const feedContainer = document.querySelector('#homefeed'); const feedDomMap = {}; const sortByCreatedAt = (evt1, evt2) => { if (evt1.created_at === evt2.created_at) { @@ -115,28 +115,31 @@ setInterval(() => { function createTextNote(evt, relay) { const {host, img, isReply, replies, time, userName} = getMetadata(evt, relay); - const name = elem('strong', {className: 'mbox-username', title: evt.pubkey}, userName); - const timeElem = elem('time', { dateTime: time.toISOString()}, formatTime(time)); - const hasLongContent = evt.content.length > 280; - const headerInfo = isReply - ? [name, ' ', timeElem] - : [name, ` on ${host} `, timeElem]; - const content = hasLongContent ? `${evt.content.slice(0, 280)}…` : evt.content; + const isLongContent = evt.content.length > 280; + const content = isLongContent ? `${evt.content.slice(0, 280)}…` : evt.content; const body = elem('div', {className: 'mbox-body'}, [ elem('header', { className: 'mbox-header', - title: `Event ${evt.id}\non ${host} ${time} - ${isReply ? `\nReply ${evt.tags[0][1]}\n` : ''}` + title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${host}\n\nEvent-id: ${evt.id} + ${isReply ? `\nReply to ${evt.tags[0][1]}\n` : ''}` }, [ - elem('small', {}, headerInfo), + elem('small', {}, [ + elem('strong', {className: 'mbox-username'}, userName), + ' ', + elem('time', {dateTime: time.toISOString()}, formatTime(time)) + ]), ]), - elem('div', {data: hasLongContent ? {append: evt.content.slice(280)} : null}, content), + elem('div', {data: isLongContent ? {append: evt.content.slice(280)} : null}, content), + elem('button', { + className: 'btn-inline', name: 'reply', type: 'button', + data: {'eventId': evt.id, relay}, + }, [elem('img', {height: 24, width: 24, src: 'assets/comment.svg'})]), elem('button', { - className: 'button-inline', - name: 'reply', type: 'button', - data: {'eventId': evt.id, relay} + className: 'btn-inline', name: 'star', type: 'button', + data: {'eventId': evt.id, relay}, }, [ - elem('small', {}, 'reply') + elem('img', {alt: '♥', height: 24, width: 24, src: 'assets/heart-fill.svg'}), + elem('small', {}, 2), ]), replies[0] ? elem('div', {className: 'mobx-replies'}, replies.map(e => createTextNote(e, relay))) : '', ]); @@ -242,15 +245,14 @@ const getHost = (url) => { function getMetadata(evt, relay) { const host = getHost(relay); const user = userList.find(user => user.pubkey === evt.pubkey); - const userImg = /*user?.metadata[relay]?.picture || */'bubble.svg'; // TODO: enable pic once we have proxy + const userImg = /*user?.metadata[relay]?.picture || */'assets/bubble.svg'; // TODO: enable pic once we have proxy const userName = user?.metadata[relay]?.name || evt.pubkey.slice(0, 8); const userAbout = user?.metadata[relay]?.about || ''; - const title = `${userName} on ${host} ${userAbout}`; const img = elem('img', { className: 'mbox-img', src: userImg, - alt: title, - title, + alt: `${userName} ${host}`, + title: `${userName} on ${host} ${userAbout}`, }, ''); const isReply = evt.tags.some(hasEventTag); const replies = replyList.filter((reply) => reply.tags[0][1] === evt.id); @@ -390,7 +392,7 @@ function validKeys(privatekey, pubkey) { } statusMessage.hidden = false; importBtn.setAttribute('disabled', true); - return false; + return false; } privateTgl.addEventListener('click', () => { diff --git a/src/tabs.css b/src/tabs.css index 4942a37..2edcedf 100644 --- a/src/tabs.css +++ b/src/tabs.css @@ -1,19 +1,12 @@ -.tabs { - position: relative; - max-width: 96ch; - min-height: 200px; -} -.tab { - float: left; -} +.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; } -.tab > label { - cursor: pointer; - padding: 1rem 1.5em; -} - -.tab [type=radio] { +input[type="radio"].tab { clip: rect(0, 0, 0, 0); height: 0; overflow: hidden; @@ -21,33 +14,45 @@ width: 0; } -.tab [type=radio] + label { +.tab + label { + border: none; + color: var(--color); + display: inline-block; outline: 2px solid var(--bgcolor-accent); - outline-offset: -1px; + padding: 1rem 1.5em; + position: relative; + top: 1px; +} +input[type="radio"]:checked + label { + background: var(--bgcolor-accent); } -/* -.tab [type=radio]:focus + label { - outline: 2px dotted black; +.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 [type=radio]:checked ~ label { - background-color: var(--bgcolor-accent); - color: var(--color); - z-index: 2; +.tab-content { + max-width: 96ch; + min-height: 200px; +} + +/* + + +.tabs { + position: relative; } -.tab [type=radio]:checked ~ label ~ .content { - opacity: 1; - z-index: 1; +.tab { + float: left; } -.tab .content { - bottom: 0; - left: 0; - opacity: 0; - position: absolute; - right: 0; - top: 10rem; +.tab > label { } + + +*/ \ No newline at end of file diff --git a/src/timeutil.js b/src/timeutil.js index 810a4f9..f798f83 100644 --- a/src/timeutil.js +++ b/src/timeutil.js @@ -45,7 +45,7 @@ const timeAgo = (time, locale = 'en') => { } else if (minutes > 0) { return relativeTime.format(0 - minutes, 'minute'); } else { - return relativeTime.format(0 - timeSince, 'second'); + return relativeTime.format(Math.round(0 - timeSince), 'second'); } }; From e7ad8e468bb6a5e7e45a59bb0292faf597bf81c1 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sun, 20 Nov 2022 14:08:38 +0100 Subject: [PATCH 22/23] feed: handle reactions (kind 7) Added support for starring. Stores recation events in a reactionMap, so rendering can use it later. Improved reply to replies, by keeping a separate replyDomMap. Changed from heart to star, reason: thumbsup or a heart are good for positive events, but not so suitable to react to a bad event. So currently negavtive votes are just counted as a star as well. Did not add another dom map, but just querySelector in case an existing star needs to be updated later. --- esbuildconf.js | 2 + src/assets/comment.svg | 2 +- src/assets/star-fill.svg | 1 + src/assets/star.svg | 1 + src/cards.css | 6 +- src/form.css | 14 +++- src/main.js | 139 ++++++++++++++++++++++++++++++++------- 7 files changed, 137 insertions(+), 28 deletions(-) create mode 100644 src/assets/star-fill.svg create mode 100644 src/assets/star.svg diff --git a/esbuildconf.js b/esbuildconf.js index 4428cd9..c0508d4 100644 --- a/esbuildconf.js +++ b/esbuildconf.js @@ -12,6 +12,8 @@ export const options = { 'src/assets/bubble.svg', 'src/assets/comment.svg', 'src/assets/heart-fill.svg', + 'src/assets/star.svg', + 'src/assets/star-fill.svg', ], outdir: 'dist', //entryNames: '[name]-[hash]', TODO: replace urls in index.html with hashed paths diff --git a/src/assets/comment.svg b/src/assets/comment.svg index 1936a6d..4ec1948 100644 --- a/src/assets/comment.svg +++ b/src/assets/comment.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/star-fill.svg b/src/assets/star-fill.svg new file mode 100644 index 0000000..cec0904 --- /dev/null +++ b/src/assets/star-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/star.svg b/src/assets/star.svg new file mode 100644 index 0000000..e212fa3 --- /dev/null +++ b/src/assets/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/cards.css b/src/cards.css index faa77c2..2d91ac1 100644 --- a/src/cards.css +++ b/src/cards.css @@ -18,9 +18,9 @@ max-width: var(--size); } .mbox-recommend-server .mbox-img { - --size: 2.5ch; - margin-left: 1ch; - margin-right: 1.5ch; + --size: 4.5ch; + margin-left: 3ch; + margin-right: 3.5ch; } .mbox-body { diff --git a/src/form.css b/src/form.css index 36324e6..3f6a634 100644 --- a/src/form.css +++ b/src/form.css @@ -70,6 +70,13 @@ button:focus { max-height: 18px; max-width: 18px; } +.btn-inline img[alt] { + color: #7f7f7f; + line-height: 1px; +} +.btn-inline img[alt]::before { + font-size: 3.4rem; +} button:disabled { background-color: var(--bgcolor-inactive); @@ -91,8 +98,13 @@ button:disabled { flex-grow: 1; gap: 1rem; } +.cards .form-inline button, +.cards .form-inline input[type="text"] { + margin: .4rem 0; + padding: .6rem 1rem; +} -.form-inline input[type=text] { +.form-inline input[type="text"] { flex-grow: 1; } diff --git a/src/main.js b/src/main.js index 9863ecb..858eb9f 100644 --- a/src/main.js +++ b/src/main.js @@ -15,6 +15,9 @@ pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); let max = 0; function onEvent(evt, relay) { + if (evt.id === '209eefe6c940377fa8730853a75d1b4bb31bd929d79') { + console.log(evt) + } // if (max++ >= 223) { // return subscription.unsub(); // } @@ -31,12 +34,14 @@ function onEvent(evt, relay) { case 3: updateContactList(evt, relay); break; + case 7: + handleReaction(evt, relay); default: // console.log(`TODO: add support for event kind ${evt.kind}`/*, evt*/) } } -// const pubkey = localStorage.getItem('pub_key') +let pubkey = localStorage.getItem('pub_key') const subscription = pool.sub({ cb: onEvent, @@ -50,12 +55,11 @@ const subscription = pool.sub({ // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55 // ], // since: new Date(Date.now() - (24 * 60 * 60 * 1000)), - limit: 100, + limit: 400, } }); const textNoteList = []; -const replyList = []; const eventRelayMap = {}; const hasEventTag = tag => tag[0] === 'e'; @@ -65,7 +69,6 @@ function handleTextNote(evt, relay) { } else { eventRelayMap[evt.id] = [relay]; if (evt.tags.some(hasEventTag)) { - replyList.push(evt); handleReply(evt, relay); } else { textNoteList.push(evt); @@ -74,9 +77,53 @@ function handleTextNote(evt, relay) { } } +const replyList = []; +const reactionMap = {}; + +function handleReaction(evt, relay) { + if (!evt.content.length) { + // console.log('reaction with no content', evt) + return; + } + const eventTags = evt.tags.filter(hasEventTag); + let replies = eventTags.filter(([tag, eventId, relayUrl, marker]) => marker === 'reply'); + if (replies.length === 0) { + // deprecated https://github.com/nostr-protocol/nips/blob/master/10.md#positional-e-tags-deprecated + replies = eventTags.filter((tags) => tags[3] === undefined); + } + if (replies.length !== 1) { + console.log('call me', evt); + return; + } + + const [tag, eventId/*, relayUrl, marker*/] = replies[0]; + + 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 = feedDomMap[eventId] || replyDomMap[eventId]; + if (article) { + const button = article.querySelector('button[name="star"]'); + const reactions = button.querySelector('[data-reactions]'); + reactions.textContent = reactionMap[eventId].length; + console.log(evt.pubkey, pubkey) + if (evt.pubkey === pubkey) { + button.querySelector('img[src$="star.svg"]').setAttribute('src', 'assets/star-fill.svg'); + } + } +} + // feed const feedContainer = document.querySelector('#homefeed'); const feedDomMap = {}; +const replyDomMap = window.replyDomMap = {}; + 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); @@ -117,16 +164,21 @@ function createTextNote(evt, relay) { const {host, img, isReply, replies, time, userName} = getMetadata(evt, relay); const isLongContent = evt.content.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 replyFeed = replies[0] ? replies.map(e => replyDomMap[e.id] = createTextNote(e, relay)) : []; 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` : ''} ${isReply ? `\nReply to ${evt.tags[0][1]}\n` : ''}` }, [ elem('small', {}, [ elem('strong', {className: 'mbox-username'}, userName), ' ', - elem('time', {dateTime: time.toISOString()}, formatTime(time)) + elem('time', {dateTime: time.toISOString()}, formatTime(time)), + ` kind:${evt.kind} ${evt.id}`, ]), ]), elem('div', {data: isLongContent ? {append: evt.content.slice(280)} : null}, content), @@ -138,24 +190,38 @@ function createTextNote(evt, relay) { className: 'btn-inline', name: 'star', type: 'button', data: {'eventId': evt.id, relay}, }, [ - elem('img', {alt: '♥', height: 24, width: 24, src: 'assets/heart-fill.svg'}), - elem('small', {}, 2), + elem('img', {alt: didReact ? '✭' : '✩', height: 24, width: 24, src: `assets/${didReact ? 'star-fill' : 'star'}.svg`}), // ♥ + elem('small', {data: {reactions: evt.id}}, hasReactions ? reactionMap[evt.id].length : ''), ]), - replies[0] ? elem('div', {className: 'mobx-replies'}, replies.map(e => createTextNote(e, relay))) : '', + replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed) : '', ]); return rendernArticle([img, body]); } function handleReply(evt, relay) { - const article = feedDomMap[evt.tags[0][1]]; - if (article) { - let replyContainer = article.querySelector('.mobx-replies'); - if (!replyContainer) { - replyContainer = elem('div', {className: 'mobx-replies'}); - article.querySelector('.mbox-body').append(replyContainer); - } - replyContainer.append(createTextNote(evt, relay)); + if (replyDomMap[evt.id]) { + console.log('CALL ME already have reply in replyDomMap', evt, relay); + return; } + replyList.push(evt); + renderReply(evt, relay); +} + +function renderReply(evt, relay) { + const eventId = evt.tags[0][1]; // TODO: double check + const article = feedDomMap[eventId] || replyDomMap[eventId]; + if (!article) { + // root article has not been rendered + return; + } + let replyContainer = article.querySelector('.mobx-replies'); + if (!replyContainer) { + replyContainer = elem('div', {className: 'mobx-replies'}); + article.querySelector('.mbox-body').append(replyContainer); + } + const reply = createTextNote(evt, relay); + replyContainer.append(reply); + replyDomMap[evt.id] = reply; } const sortEventCreatedAt = (created_at) => ( @@ -189,11 +255,11 @@ function renderRecommendServer(evt, relay) { ]), ` recommends server: ${evt.content}`, ]); - return rendernArticle([img, body], {className: 'mbox-recommend-server'}); + return rendernArticle([img, body], {className: 'mbox-recommend-server', data: {relay: evt.content}}); } -function rendernArticle(content, props) { - const className = ['mbox', props?.className].join(' '); +function rendernArticle(content, props = {}) { + const className = props.className ? ['mbox', props?.className].join(' ') : 'mbox'; return elem('article', {...props, className}, content); } @@ -292,8 +358,34 @@ feedContainer.addEventListener('click', (e) => { input.focus(); return; } + if (button && button.name === 'star') { + upvote(button.dataset.eventId, button.dataset.relay) + return; + } }); +async function upvote(eventId, relay) { + const privatekey = localStorage.getItem('private_key'); + const newReaction = { + kind: 7, + pubkey, // TODO: lib could check that this is the pubkey of the key to sign with + content: '+', + tags: [['e', eventId, relay, 'reply']], + created_at: Math.floor(Date.now() * 0.001), + }; + const sig = await signEvent(newReaction, privatekey).catch(console.error); + if (sig) { + const ev = await pool.publish({...newReaction, sig}, (status, url) => { + if (status === 0) { + console.info(`publish request sent to ${url}`); + } + if (status === 1) { + console.info(`event published by ${url}`); + } + }).catch(console.error); + } +} + // send const sendStatus = document.querySelector('#sendstatus'); const onSendError = err => { @@ -302,7 +394,7 @@ const onSendError = err => { }; const publish = document.querySelector('#publish'); publish.addEventListener('click', async () => { - const pubkey = localStorage.getItem('pub_key'); + // const pubkey = localStorage.getItem('pub_key'); const privatekey = localStorage.getItem('private_key'); if (!pubkey || !privatekey) { return onSendError(new Error('no pubkey/privatekey')); @@ -364,12 +456,13 @@ generateBtn.addEventListener('click', () => { importBtn.addEventListener('click', () => { const privatekey = privateKeyInput.value; - const pubkey = pubKeyInput.value; - if (validKeys(privatekey, pubkey)) { + const pubkeyInput = pubKeyInput.value; + if (validKeys(privatekey, pubkeyInput)) { localStorage.setItem('private_key', privatekey); - localStorage.setItem('pub_key', pubkey); + localStorage.setItem('pub_key', pubkeyInput); statusMessage.textContent = 'stored private and public key locally!'; statusMessage.hidden = false; + pubkey = pubkeyInput; } }); From 4a710ee5d147f892dfe33f157f5c3abc53d47b55 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Mon, 21 Nov 2022 22:04:39 +0100 Subject: [PATCH 23/23] getting ready - added favicon.ico to get rid of anoying logs in the future - clicking the idea bubble brings back the text input field - stop accidentally submitting the reply form - updated idea cloud to dark/light mode neutral gray - contact list not needed yet, disabled - add a detailed list of reactions in star tooltip - removed debug debounce was for testing chronological feed order - started username prototype --- esbuildconf.js | 3 +- src/assets/bubble.svg | 4 +- src/cards.css | 16 ++++- src/favicon.ico | Bin 0 -> 4158 bytes src/form.css | 13 +++- src/index.html | 18 ++++-- src/main.css | 31 ++++++--- src/main.js | 145 +++++++++++++++++++++++++----------------- src/tabs.css | 5 +- 9 files changed, 153 insertions(+), 82 deletions(-) create mode 100644 src/favicon.ico diff --git a/esbuildconf.js b/esbuildconf.js index c0508d4..43e48a8 100644 --- a/esbuildconf.js +++ b/esbuildconf.js @@ -14,10 +14,11 @@ export const options = { 'src/assets/heart-fill.svg', 'src/assets/star.svg', 'src/assets/star-fill.svg', + 'src/favicon.ico', ], outdir: 'dist', //entryNames: '[name]-[hash]', TODO: replace urls in index.html with hashed paths - loader: {'.html': 'copy', '.svg': 'copy'}, + loader: {'.html': 'copy', '.svg': 'copy', '.ico': 'copy'}, bundle: true, platform: 'browser', minify: false, // TODO: true for release and enable sourcemap diff --git a/src/assets/bubble.svg b/src/assets/bubble.svg index 583c79f..9b9d947 100644 --- a/src/assets/bubble.svg +++ b/src/assets/bubble.svg @@ -1,4 +1,6 @@ - + + + diff --git a/src/cards.css b/src/cards.css index 2d91ac1..5db1daa 100644 --- a/src/cards.css +++ b/src/cards.css @@ -17,6 +17,8 @@ max-width: var(--size); max-width: var(--size); } + +.mbox-updated-contact .mbox-img, .mbox-recommend-server .mbox-img { --size: 4.5ch; margin-left: 3ch; @@ -27,7 +29,6 @@ flex-basis: calc(100% - 64px - 1rem); flex-grow: 0; flex-shrink: 1; - max-width: 96ch; word-break: break-word; } @@ -38,15 +39,26 @@ margin-top: 0; } .mbox-header time, -.mbox-username { +.mbox-username, +.mbox-updated-contact, +.mbox mbox-recommend-server { + color: var(--color-accent); } +.mbox-updated-contact .mbox-body, .mbox-recommend-server .mbox-body { display: block; flex-basis: 100%; font-size: var(--font-small); + overflow: scroll; } +.mbox-updated-contact .mbox-header, .mbox-recommend-server .mbox-header { display: inline; } + +.mbox-updated-contact { + padding: 0 0 1rem 0; + margin: 0; +} \ No newline at end of file diff --git a/src/favicon.ico b/src/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9b1f7e628689fdaca9e7e531c83b022719796bfd GIT binary patch literal 4158 zcmcIm%T8255G@yiJ25c`JC-aY{(>7tga6>@PmoLkgYge!0R0!o(Zr}rKL8m`=w<4HN zfq4zs1fBr9!1obe<2wKz0OXu4@>vqwd=CD$fGe+l6_8S1_sM^v# zZP$V6RvyeR!sgioadmtz;A^gE4CYJVei#$`7j)eJhd^%xwuiuPKx4=Ld+07VYRL+F z-4uHt`a8f(qYv{7(BB0PfEYLZat!%15%!zwl=9jB19X9@nnsziAAGHiY@;-OpF>_h z59aqEkDU1Z3faw?G-^-!ny={vRBM1LroiyT1IsO)YcnP-pifMyS8!o9~E@($&g{ za(+u?;r*cOtt0>D^N9CD#=tvs<%px!rG0a*vEILoHDB>w*ncNaD~Z~EpNFMqd(efR{uj)DC>;5N{ln<2+K;OJc+ z{qb#k{O*a;^lN?voTf35$@(w=cxHPeu+3Vc`JH

nuaW+{v9unxJsP-bcWC47#b zw|WZafoD?lLYd?GwB@@xzu)65qb{rk*H>#)WaPf&@=e=r&N)v}7V0Exbsu;N>;jx0 O!8T>o@jY3bEB^o - nostr sandbox + nostr
- + - +
- +
-
+ - +
@@ -46,6 +46,10 @@
+
diff --git a/src/main.css b/src/main.css index 7052d73..61d53db 100644 --- a/src/main.css +++ b/src/main.css @@ -24,23 +24,25 @@ @media (prefers-color-scheme: light) { html { - --bgcolor: #fff; - --bgcolor-accent: #ff731d; + --bgcolor: #fdfefa; + --bgcolor-accent: #37ff1d; --bgcolor-inactive: #bababa; + --bgcolor-textinput: #fff; --color: rgb(68 68 68); --color-accent: #ff731d; - --bgcolor-danger: rgb(255 0 0); + --bgcolor-danger: rgb(255, 80, 80); } } @media (prefers-color-scheme: dark) { html { --bgcolor: #191919; - --bgcolor-accent: #2d4263; - --bgcolor-inactive: #535353; - --color: #c84b31; - --color-accent: #ecdbba; - --bgcolor-danger: rgb(255 0 0); + --bgcolor-accent: #1e437d; + --bgcolor-inactive: #333333; + --bgcolor-textinput: #0e0e0e; + --color: #fff; + --color-accent: #bbb;; + --bgcolor-danger: rgb(169, 0, 0); } img { @@ -81,14 +83,27 @@ time { background-color: var(--bgcolor-danger); } +a { + color: var(--color-accent); +} + a:focus { border-radius: var(--focus-border-radius); outline: var(--focus-outline); outline-offset: 0; } +a:visited { + color: darkmagenta; +} + img[alt] { font-size: .9rem; text-align: center; word-break: break-all; } + +pre { + margin: 0; + padding: .5rem 0; +} diff --git a/src/main.js b/src/main.js index 858eb9f..9fca2fd 100644 --- a/src/main.js +++ b/src/main.js @@ -4,23 +4,14 @@ import {dateTime, formatTime} from './timeutil.js'; // curl -H 'accept: application/nostr+json' https://nostr.x1ddos.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.damus.io', {read: true, write: true}); pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true}); // pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); // pool.addRelay('wss://nostr.bitcoiner.social/', {read: true, write: true}); // read only // pool.addRelay('wss://nostr.rocks', {read: true, write: false}); - -let max = 0; - function onEvent(evt, relay) { - if (evt.id === '209eefe6c940377fa8730853a75d1b4bb31bd929d79') { - console.log(evt) - } - // if (max++ >= 223) { - // return subscription.unsub(); - // } switch (evt.kind) { case 0: handleMetadata(evt, relay); @@ -32,7 +23,7 @@ function onEvent(evt, relay) { handleRecommendServer(evt, relay); break; case 3: - updateContactList(evt, relay); + // handleContactList(evt, relay); break; case 7: handleReaction(evt, relay); @@ -41,7 +32,13 @@ function onEvent(evt, relay) { } } -let pubkey = localStorage.getItem('pub_key') +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 subscription = pool.sub({ cb: onEvent, @@ -55,7 +52,7 @@ const subscription = pool.sub({ // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55 // ], // since: new Date(Date.now() - (24 * 60 * 60 * 1000)), - limit: 400, + limit: 500, } }); @@ -112,9 +109,10 @@ function handleReaction(evt, relay) { const button = article.querySelector('button[name="star"]'); const reactions = button.querySelector('[data-reactions]'); reactions.textContent = reactionMap[eventId].length; - console.log(evt.pubkey, pubkey) if (evt.pubkey === pubkey) { - button.querySelector('img[src$="star.svg"]').setAttribute('src', 'assets/star-fill.svg'); + const star = button.querySelector('img[src$="star.svg"]'); + star.setAttribute('src', 'assets/star-fill.svg'); + star.setAttribute('title', reactionMap[eventId]) } } } @@ -131,14 +129,8 @@ const sortByCreatedAt = (evt1, evt2) => { return evt1.created_at > evt2.created_at ? -1 : 1; }; -// let debounceDebugMessageTimer; function renderFeed() { const sortedFeeds = textNoteList.sort(sortByCreatedAt).reverse(); - // debug - // clearTimeout(debounceDebugMessageTimer); - // debounceDebugMessageTimer = setTimeout(() => { - // console.log(`${sortedFeeds.reverse().map(e => dateTime.format(e.created_at * 1000)).join('\n')}`) - // }, 2000); sortedFeeds.forEach((textNoteEvent, i) => { if (feedDomMap[textNoteEvent.id]) { // TODO check eventRelayMap if event was published to different relays @@ -178,22 +170,26 @@ function createTextNote(evt, relay) { elem('strong', {className: 'mbox-username'}, userName), ' ', elem('time', {dateTime: time.toISOString()}, formatTime(time)), - ` kind:${evt.kind} ${evt.id}`, ]), ]), elem('div', {data: isLongContent ? {append: evt.content.slice(280)} : null}, content), - elem('button', { - className: 'btn-inline', name: 'reply', type: 'button', - data: {'eventId': evt.id, relay}, - }, [elem('img', {height: 24, width: 24, src: 'assets/comment.svg'})]), elem('button', { className: 'btn-inline', name: 'star', type: 'button', data: {'eventId': evt.id, relay}, }, [ - elem('img', {alt: didReact ? '✭' : '✩', height: 24, width: 24, src: `assets/${didReact ? 'star-fill' : 'star'}.svg`}), // ♥ + elem('img', { + alt: didReact ? '✭' : '✩', // ♥ + height: 24, width: 24, + src: `assets/${didReact ? 'star-fill' : 'star'}.svg`, + title: reactionMap[evt.id]?.map(({content}) => content).join(' '), + }), elem('small', {data: {reactions: evt.id}}, hasReactions ? reactionMap[evt.id].length : ''), ]), - replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed) : '', + elem('button', { + className: 'btn-inline', name: 'reply', type: 'button', + data: {'eventId': evt.id, relay}, + }, [elem('img', {height: 24, width: 24, src: 'assets/comment.svg'})]), + replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '', ]); return rendernArticle([img, body]); } @@ -210,8 +206,7 @@ function handleReply(evt, relay) { function renderReply(evt, relay) { const eventId = evt.tags[0][1]; // TODO: double check const article = feedDomMap[eventId] || replyDomMap[eventId]; - if (!article) { - // root article has not been rendered + if (!article) { // root article has not been rendered return; } let replyContainer = article.querySelector('.mobx-replies'); @@ -245,6 +240,45 @@ function handleRecommendServer(evt, relay) { feedDomMap[evt.id] = art; } +function handleContactList(evt, relay) { + if (feedDomMap[evt.id]) { + return; + } + const art = renderUpdateContact(evt, relay); + if (textNoteList.length < 2) { + feedContainer.append(art); + return; + } + const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); + feedDomMap[closestTextNotes[0].id].after(art); + feedDomMap[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 rendernArticle([img, body], {className: 'mbox-updated-contact'}); +} + function renderRecommendServer(evt, relay) { const {img, time, userName} = getMetadata(evt, relay); const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ @@ -264,7 +298,7 @@ function rendernArticle(content, props = {}) { } const userList = []; -const tempContactList = {}; +// const tempContactList = {}; function handleMetadata(evt, relay) { try { @@ -280,9 +314,7 @@ function setMetadata(evt, relay, content) { const user = userList.find(u => u.pubkey === evt.pubkey); if (!user) { userList.push({ - metadata: { - [relay]: content - }, + metadata: {[relay]: content}, pubkey: evt.pubkey, }); } else { @@ -292,12 +324,12 @@ function setMetadata(evt, relay, content) { ...content, }; } - if (tempContactList[relay]) { - const updates = tempContactList[relay].filter(update => update.pubkey === evt.pubkey); - if (updates) { - // console.log('TODO: add contact list (kind 3)', updates); - } - } + // if (tempContactList[relay]) { + // const updates = tempContactList[relay].filter(update => update.pubkey === evt.pubkey); + // if (updates) { + // console.log('TODO: add contact list (kind 3)', updates); + // } + // } } const getHost = (url) => { @@ -326,19 +358,6 @@ function getMetadata(evt, relay) { return {host, img, isReply, replies, time, userName}; } -function updateContactList(evt, relay) { - 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]; - } -} - -// check pool.status - // reply const writeForm = document.querySelector('#writeForm'); const input = document.querySelector('input[name="message"]'); @@ -351,8 +370,9 @@ feedContainer.addEventListener('click', (e) => { lastReplyBtn.hidden = false; } lastReplyBtn = button; - button.hidden = true; + // button.hidden = true; button.after(writeForm); + button.after(sendStatus); writeForm.hidden = false; replyTo = ['e', button.dataset.eventId, button.dataset.relay]; input.focus(); @@ -364,6 +384,14 @@ feedContainer.addEventListener('click', (e) => { } }); +const newMessageDiv = document.querySelector('#newMessage'); +document.querySelector('#bubble').addEventListener('click', (e) => { + replyTo = null; + newMessageDiv.prepend(writeForm); + newMessageDiv.append(sendStatus); + input.focus(); +}); + async function upvote(eventId, relay) { const privatekey = localStorage.getItem('private_key'); const newReaction = { @@ -393,7 +421,8 @@ const onSendError = err => { sendStatus.hidden = false; }; const publish = document.querySelector('#publish'); -publish.addEventListener('click', async () => { +writeForm.addEventListener('submit', async (e) => { + e.preventDefault(); // const pubkey = localStorage.getItem('pub_key'); const privatekey = localStorage.getItem('private_key'); if (!pubkey || !privatekey) { @@ -424,7 +453,8 @@ publish.addEventListener('click', async () => { lastReplyBtn.hidden = false; lastReplyBtn = null; replyTo = null; - document.querySelector('#newMessage').append(writeForm); + newMessageDiv.append(writeForm); + newMessageDiv.append(sendStatus); } // console.info(`event published by ${url}`, ev); } @@ -433,6 +463,7 @@ publish.addEventListener('click', async () => { }); input.addEventListener('input', () => publish.disabled = !input.value); +input.addEventListener('blur', () => sendStatus.textContent = ''); // settings const form = document.querySelector('form[name="settings"]'); @@ -502,4 +533,4 @@ document.body.addEventListener('click', (e) => { delete append.dataset.append; return; } -}); \ No newline at end of file +}); diff --git a/src/tabs.css b/src/tabs.css index 2edcedf..e92c055 100644 --- a/src/tabs.css +++ b/src/tabs.css @@ -1,4 +1,4 @@ - +.tabs { margin-top: 4rem; } .tabs .tab-content { display: none; } #feed:checked ~ .tabs .tab-content:nth-child(1), #trending:checked ~ .tabs .tab-content:nth-child(2), @@ -43,9 +43,6 @@ input[type="radio"]:checked + label { /* -.tabs { - position: relative; -} .tab { float: left;