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 {
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;

@ -10,7 +10,7 @@
* @param {Array<HTMLElement|string>} 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;
}

@ -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 {

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

@ -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);
}
}

@ -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

@ -34,7 +34,7 @@
.tab [type=radio]:checked ~ label {
background-color: var(--bgcolor-accent);
color: var(--color-accent);
color: var(--color);
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