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.
OFF0 2 years ago
parent 13b3db4302
commit e7ad8e468b
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

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

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="21" viewBox="0 0 80.035 70.031"><path fill="currentColor" d="M0 31.347q0-5.113 2.02-9.875 2.019-4.761 5.724-8.633 3.705-3.872 8.615-6.762 4.91-2.89 11.023-4.484Q33.496 0 40.018 0q6.52 0 12.635 1.593 6.113 1.594 11.023 4.484 4.91 2.89 8.615 6.762 3.705 3.872 5.725 8.633 2.019 4.762 2.019 9.875 0 6.373-3.168 12.153t-8.522 9.968q-5.354 4.187-12.765 6.67-7.41 2.482-15.562 2.482-7.967 0-15.303-2.371l-9.116 6.447q-5.113 3.223-7.188 1.871-2.075-1.352-.926-7.614l1.89-9.486q-4.484-4.15-6.93-9.3Q0 37.017 0 31.347Z" style="stroke-width:.0370534"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="21" viewBox="0 0 80.035 70.031"><path fill="transparent" stroke="#7f7f7f" stroke-width="4.927" d="M2.463 31.824q0-4.789 1.893-9.248 1.892-4.46 5.361-8.087 3.47-3.626 8.07-6.333 4.598-2.707 10.324-4.2 5.727-1.493 11.836-1.493 6.107 0 11.834 1.492 5.725 1.494 10.325 4.2 4.599 2.708 8.07 6.334 3.47 3.627 5.362 8.087 1.891 4.46 1.891 9.248 0 5.97-2.967 11.384-2.967 5.414-7.982 9.336-5.015 3.922-11.957 6.248-6.94 2.325-14.576 2.325-7.463 0-14.334-2.221l-8.537 6.038q-4.789 3.02-6.733 1.752-1.943-1.266-.867-7.13l1.77-8.886q-4.2-3.887-6.49-8.71-2.29-4.825-2.29-10.136Z"/></svg>

Before

Width:  |  Height:  |  Size: 607 B

After

Width:  |  Height:  |  Size: 634 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#7f7f7f" d="M12.672.668a.75.75 0 00-1.345 0L8.27 6.865l-6.838.994a.75.75 0 00-.416 1.279l4.948 4.823-1.168 6.811a.75.75 0 001.088.791L12 18.347l6.117 3.216a.75.75 0 001.088-.79l-1.168-6.812 4.948-4.823a.75.75 0 00-.416-1.28l-6.838-.993L12.672.668z"></path></svg>

After

Width:  |  Height:  |  Size: 357 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#7f7f7f" d="M12 .25a.75.75 0 01.673.418l3.058 6.197 6.839.994a.75.75 0 01.415 1.279l-4.948 4.823 1.168 6.811a.75.75 0 01-1.088.791L12 18.347l-6.117 3.216a.75.75 0 01-1.088-.79l1.168-6.812-4.948-4.823a.75.75 0 01.416-1.28l6.838-.993L11.328.668A.75.75 0 0112 .25zm0 2.445L9.44 7.882a.75.75 0 01-.565.41l-5.725.832 4.143 4.038a.75.75 0 01.215.664l-.978 5.702 5.121-2.692a.75.75 0 01.698 0l5.12 2.692-.977-5.702a.75.75 0 01.215-.664l4.143-4.038-5.725-.831a.75.75 0 01-.565-.41L12 2.694z"></path></svg>

After

Width:  |  Height:  |  Size: 592 B

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

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

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

Loading…
Cancel
Save