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).
pull/2/head
OFF0 2 years ago
parent 397df6d5a4
commit 2d8e60a6df
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -17,11 +17,11 @@
} }
.mbox-body { .mbox-body {
color: var(--color-accent);
flex-basis: calc(100% - 64px - 1rem); flex-basis: calc(100% - 64px - 1rem);
flex-grow: 0; flex-grow: 0;
flex-shrink: 1; flex-shrink: 1;
max-width: 96ch; max-width: 96ch;
word-break: break-word;
} }
.mbox-header { .mbox-header {
@ -30,6 +30,10 @@
flex-shrink: 1; flex-shrink: 1;
margin-top: 0; margin-top: 0;
} }
.mbox-header time,
.mbox-username {
color: var(--color-accent);
}
.mbox-recommend-server .mbox-body { .mbox-recommend-server .mbox-body {
display: block; display: block;

@ -10,7 +10,7 @@
* @param {Array<HTMLElement|string>} children * @param {Array<HTMLElement|string>} children
* @return HTMLElement * @return HTMLElement
*/ */
export function elem(name = 'div', props = {}, children = []) { export function elem(name = 'div', {data, ...props} = {}, children = []) {
const el = document.createElement(name); const el = document.createElement(name);
Object.assign(el, props); Object.assign(el, props);
if (typeof children === 'string') { if (typeof children === 'string') {
@ -18,5 +18,8 @@ export function elem(name = 'div', props = {}, children = []) {
} else { } else {
el.append(...children); el.append(...children);
} }
if (data) {
Object.entries(data).forEach(([key, value]) => el.dataset[key] = value);
}
return el; return el;
} }

@ -21,7 +21,7 @@ label {
} }
label { label {
color: var(--bgcolor-accent); color: var(--color-accent);
} }
input[type="password"], input[type="password"],
@ -52,6 +52,7 @@ button {
border-radius: .2rem; border-radius: .2rem;
cursor: pointer; cursor: pointer;
outline-offset: 1px; outline-offset: 1px;
word-break: normal;
} }
button:focus { button:focus {

@ -14,8 +14,8 @@
<div class="content"> <div class="content">
<article class="mbox"> <article class="mbox">
<img class="mbox-img" src="bubble.svg"> <img class="mbox-img" src="bubble.svg">
<div class="mbox-body"> <div class="mbox-body" id="newMessage">
<form class="form-inline"> <form class="form-inline" id="writeForm">
<input type="text" name="message"> <input type="text" name="message">
<button type="button" id="publish" disabled>send</button> <button type="button" id="publish" disabled>send</button>
</form> </form>

@ -21,7 +21,7 @@
--bgcolor-accent: #ff731d; --bgcolor-accent: #ff731d;
--bgcolor-inactive: #bababa; --bgcolor-inactive: #bababa;
--color: rgb(68 68 68); --color: rgb(68 68 68);
--color-accent: rgb(0 0 0); --color-accent: #ff731d;
--bgcolor-danger: rgb(255 0 0); --bgcolor-danger: rgb(255 0 0);
} }
} }

@ -1,26 +1,23 @@
import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools';
import {elem} from './domutil.js'; import {elem} from './domutil.js';
import {dateTime, formatTime} from './timeutil.js';
// curl -H 'accept: application/nostr+json' https://nostr.x1ddos.ch // curl -H 'accept: application/nostr+json' https://nostr.x1ddos.ch
const pool = relayPool(); 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.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 // read only
// pool.addRelay('wss://nostr.rocks', {read: true, write: false}); // 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; let max = 0;
function onEvent(evt, relay) { function onEvent(evt, relay) {
if (max++ >= 223) { // if (max++ >= 223) {
return subscription.unsub(); // return subscription.unsub();
} // }
switch (evt.kind) { switch (evt.kind) {
case 0: case 0:
handleMetadata(evt, relay); handleMetadata(evt, relay);
@ -52,7 +49,8 @@ const subscription = pool.sub({
// // pubkey, // me // // pubkey, // me
// '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55 // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55
// ], // ],
limit: 200, // since: new Date(Date.now() - (24 * 60 * 60 * 1000)),
limit: 2000,
} }
}); });
@ -67,10 +65,8 @@ function handleTextNote(evt, relay) {
} else { } else {
eventRelayMap[evt.id] = [relay]; eventRelayMap[evt.id] = [relay];
if (evt.tags.some(hasEventTag)) { if (evt.tags.some(hasEventTag)) {
replyList.push(evt) replyList.push(evt);
if (feedDomMap[evt.tags[0][1]]) { handleReply(evt, relay);
console.log('CALL ME', evt.tags[0][1], feedDomMap[evt.tags[0][1]]);
}
} else { } else {
textNoteList.push(evt); textNoteList.push(evt);
} }
@ -88,62 +84,88 @@ const sortByCreatedAt = (evt1, evt2) => {
return evt1.created_at > evt2.created_at ? -1 : 1; return evt1.created_at > evt2.created_at ? -1 : 1;
}; };
let debounceDebugMessageTimer; // let debounceDebugMessageTimer;
function renderFeed() { function renderFeed() {
const sortedFeeds = textNoteList.sort(sortByCreatedAt).reverse(); const sortedFeeds = textNoteList.sort(sortByCreatedAt).reverse();
// debug // debug
clearTimeout(debounceDebugMessageTimer); // clearTimeout(debounceDebugMessageTimer);
debounceDebugMessageTimer = setTimeout(() => { // debounceDebugMessageTimer = setTimeout(() => {
console.log(`${sortedFeeds.reverse().map(e => dateTime.format(e.created_at * 1000)).join('\n')}`) // console.log(`${sortedFeeds.reverse().map(e => dateTime.format(e.created_at * 1000)).join('\n')}`)
}, 2000); // }, 2000);
sortedFeeds.forEach((textNoteEvent, i) => { sortedFeeds.forEach((textNoteEvent, i) => {
if (feedDomMap[textNoteEvent.id]) { if (feedDomMap[textNoteEvent.id]) {
// TODO check eventRelayMap if event was published to different relays // TODO check eventRelayMap if event was published to different relays
return; return;
} }
const article = createTextNote(textNoteEvent, eventRelayMap[textNoteEvent.id]); const article = createTextNote(textNoteEvent, eventRelayMap[textNoteEvent.id]);
feedDomMap[textNoteEvent.id] = article;
if (i === 0) { if (i === 0) {
feedContainer.append(article); feedContainer.append(article);
} else { } else {
feedDomMap[sortedFeeds[i - 1].id].before(article); feedDomMap[sortedFeeds[i - 1].id].before(article);
} }
feedDomMap[textNoteEvent.id] = article;
}); });
} }
const sortEventCreatedAt = (evt) => ( setInterval(() => {
{created_at: a}, document.querySelectorAll('time[datetime]').forEach(timeElem => {
{created_at: b}, timeElem.textContent = formatTime(new Date(timeElem.dateTime));
) => ( });
Math.abs(a - evt.created_at) < Math.abs(b - evt.created_at) ? -1 : 1 }, 10000);
);
function createTextNote(evt, relay) { function createTextNote(evt, relay) {
const {host, img, isReply, replies, time, userName} = getMetadata(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 ? [ const headerInfo = isReply ? [
elem('strong', {title: evt.pubkey}, userName) name, ' ', timeElem
] : [ ] : [
elem('strong', {title: evt.pubkey}, userName), name,
elem('span', { elem('span', {
title: `Event ${evt.id} title: `Event ${evt.id}
${isReply ? `\nReply ${evt.tags[0][1]}\n` : ''}\n${time}` ${isReply ? `\nReply ${evt.tags[0][1]}\n` : ''}`
}, ` on ${host} `), }, ` on ${host} `),
timeElem,
]; ];
const body = elem('div', {className: 'mbox-body'}, [ const body = elem('div', {className: 'mbox-body'}, [
elem('header', { elem('header', {className: 'mbox-header'}, [
className: 'mbox-header',
}, [
elem('small', {}, headerInfo), elem('small', {}, headerInfo),
]), ]),
evt.content, // text 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))) : '', replies[0] ? elem('div', {className: 'mobx-replies'}, replies.map(e => createTextNote(e, relay))) : '',
]); ]);
return rendernArticle([img, body]); 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) { function handleRecommendServer(evt, relay) {
if (feedDomMap[evt.id]) { if (feedDomMap[evt.id]) {
// TODO event might also be published to different relays
return; return;
} }
const art = renderRecommendServer(evt, relay); const art = renderRecommendServer(evt, relay);
@ -151,21 +173,20 @@ function handleRecommendServer(evt, relay) {
feedContainer.append(art); feedContainer.append(art);
return; return;
} }
const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt)); const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at));
feedDomMap[closestTextNotes[0].id].after(art); feedDomMap[closestTextNotes[0].id].after(art);
feedDomMap[evt.id] = art; feedDomMap[evt.id] = art;
} }
function renderRecommendServer(evt, relay) { 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)}, [ const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [
elem('header', {className: 'mbox-header'}, [ elem('header', {className: 'mbox-header'}, [
elem('small', {}, [ elem('small', {}, [
elem('strong', {}, userName), elem('strong', {}, userName)
` on ${host} `,
]), ]),
]), ]),
`recommends server: ${evt.content}`, ` recommends server: ${evt.content}`,
]); ]);
return rendernArticle([img, body], {className: 'mbox-recommend-server'}); return rendernArticle([img, body], {className: 'mbox-recommend-server'});
} }
@ -215,13 +236,13 @@ function setMetadata(evt, relay, content) {
const getHost = (url) => { const getHost = (url) => {
try { try {
return new URL(url).host; return new URL(url).host;
} catch(e) { } catch(err) {
return false; return err;
} }
} }
function getMetadata(evt, relay) { function getMetadata(evt, relay) {
const host = getHost(relay[0]); const host = getHost(relay);
const user = userList.find(user => user.pubkey === evt.pubkey); 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 || */'bubble.svg'; // TODO: enable pic once we have proxy
const userName = user?.metadata[relay]?.name || evt.pubkey.slice(0, 8); const userName = user?.metadata[relay]?.name || evt.pubkey.slice(0, 8);
@ -231,9 +252,8 @@ function getMetadata(evt, relay) {
className: 'mbox-img', className: 'mbox-img',
src: userImg, src: userImg,
alt: `${userName}@${host}`, alt: `${userName}@${host}`,
title: userAbout}, title: userAbout,
'' }, '');
);
const replies = replyList.filter((reply) => reply.tags[0][1] === evt.id); const replies = replyList.filter((reply) => reply.tags[0][1] === evt.id);
const time = new Date(evt.created_at * 1000); const time = new Date(evt.created_at * 1000);
return {host, img, isReply, replies, time, userName}; return {host, img, isReply, replies, time, userName};
@ -252,6 +272,26 @@ function updateContactList(evt, relay) {
// check pool.status // 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 // send
const sendStatus = document.querySelector('#sendstatus'); const sendStatus = document.querySelector('#sendstatus');
const onSendError = err => { const onSendError = err => {
@ -268,11 +308,12 @@ publish.addEventListener('click', async () => {
if (!input.value) { if (!input.value) {
return onSendError(new Error('message is empty')); return onSendError(new Error('message is empty'));
} }
const tags = replyTo ? [replyTo] : [];
const newEvent = { const newEvent = {
kind: 1, kind: 1,
pubkey, pubkey,
content: input.value, content: input.value,
tags: [], tags,
created_at: Math.floor(Date.now() * 0.001), created_at: Math.floor(Date.now() * 0.001),
}; };
const sig = await signEvent(newEvent, privatekey).catch(onSendError); const sig = await signEvent(newEvent, privatekey).catch(onSendError);
@ -285,13 +326,18 @@ publish.addEventListener('click', async () => {
sendStatus.hidden = true; sendStatus.hidden = true;
input.value = ''; input.value = '';
publish.disabled = true; 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); input.addEventListener('input', () => publish.disabled = !input.value);
// settings // settings

@ -34,7 +34,7 @@
.tab [type=radio]:checked ~ label { .tab [type=radio]:checked ~ label {
background-color: var(--bgcolor-accent); background-color: var(--bgcolor-accent);
color: var(--color-accent); color: var(--color);
z-index: 2; z-index: 2;
} }

@ -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);
};
};
Loading…
Cancel
Save