From 2d8e60a6df3f00ed231c31f1a2bbb85296aa2486 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Tue, 15 Nov 2022 09:05:53 +0100 Subject: [PATCH] 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); + }; +};