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/bubble.svg',
'src/assets/comment.svg', 'src/assets/comment.svg',
'src/assets/heart-fill.svg', 'src/assets/heart-fill.svg',
'src/assets/star.svg',
'src/assets/star-fill.svg',
], ],
outdir: 'dist', outdir: 'dist',
//entryNames: '[name]-[hash]', TODO: replace urls in index.html with hashed paths //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); max-width: var(--size);
} }
.mbox-recommend-server .mbox-img { .mbox-recommend-server .mbox-img {
--size: 2.5ch; --size: 4.5ch;
margin-left: 1ch; margin-left: 3ch;
margin-right: 1.5ch; margin-right: 3.5ch;
} }
.mbox-body { .mbox-body {

@ -70,6 +70,13 @@ button:focus {
max-height: 18px; max-height: 18px;
max-width: 18px; max-width: 18px;
} }
.btn-inline img[alt] {
color: #7f7f7f;
line-height: 1px;
}
.btn-inline img[alt]::before {
font-size: 3.4rem;
}
button:disabled { button:disabled {
background-color: var(--bgcolor-inactive); background-color: var(--bgcolor-inactive);
@ -91,8 +98,13 @@ button:disabled {
flex-grow: 1; flex-grow: 1;
gap: 1rem; 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; flex-grow: 1;
} }

@ -15,6 +15,9 @@ pool.addRelay('wss://nostr.x1ddos.ch', {read: true, write: true});
let max = 0; let max = 0;
function onEvent(evt, relay) { function onEvent(evt, relay) {
if (evt.id === '209eefe6c940377fa8730853a75d1b4bb31bd929d79') {
console.log(evt)
}
// if (max++ >= 223) { // if (max++ >= 223) {
// return subscription.unsub(); // return subscription.unsub();
// } // }
@ -31,12 +34,14 @@ function onEvent(evt, relay) {
case 3: case 3:
updateContactList(evt, relay); updateContactList(evt, relay);
break; break;
case 7:
handleReaction(evt, relay);
default: default:
// console.log(`TODO: add support for event kind ${evt.kind}`/*, evt*/) // 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({ const subscription = pool.sub({
cb: onEvent, cb: onEvent,
@ -50,12 +55,11 @@ const subscription = pool.sub({
// '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55 // '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245', // jb55
// ], // ],
// since: new Date(Date.now() - (24 * 60 * 60 * 1000)), // since: new Date(Date.now() - (24 * 60 * 60 * 1000)),
limit: 100, limit: 400,
} }
}); });
const textNoteList = []; const textNoteList = [];
const replyList = [];
const eventRelayMap = {}; const eventRelayMap = {};
const hasEventTag = tag => tag[0] === 'e'; const hasEventTag = tag => tag[0] === 'e';
@ -65,7 +69,6 @@ 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);
handleReply(evt, relay); handleReply(evt, relay);
} else { } else {
textNoteList.push(evt); 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 // feed
const feedContainer = document.querySelector('#homefeed'); const feedContainer = document.querySelector('#homefeed');
const feedDomMap = {}; const feedDomMap = {};
const replyDomMap = window.replyDomMap = {};
const sortByCreatedAt = (evt1, evt2) => { const sortByCreatedAt = (evt1, evt2) => {
if (evt1.created_at === evt2.created_at) { if (evt1.created_at === evt2.created_at) {
// console.log('TODO: OMG exactly at the same time, figure out how to sort then', evt1, evt2); // 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 {host, img, isReply, replies, time, userName} = getMetadata(evt, relay);
const isLongContent = evt.content.length > 280; const isLongContent = evt.content.length > 280;
const content = isLongContent ? `${evt.content.slice(0, 280)}` : evt.content; 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'}, [ const body = elem('div', {className: 'mbox-body'}, [
elem('header', { elem('header', {
className: 'mbox-header', className: 'mbox-header',
title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${host}\n\nEvent-id: ${evt.id} 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` : ''}` ${isReply ? `\nReply to ${evt.tags[0][1]}\n` : ''}`
}, [ }, [
elem('small', {}, [ elem('small', {}, [
elem('strong', {className: 'mbox-username'}, userName), 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), 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', className: 'btn-inline', name: 'star', type: 'button',
data: {'eventId': evt.id, relay}, data: {'eventId': evt.id, relay},
}, [ }, [
elem('img', {alt: '♥', height: 24, width: 24, src: 'assets/heart-fill.svg'}), elem('img', {alt: didReact ? '✭' : '✩', height: 24, width: 24, src: `assets/${didReact ? 'star-fill' : 'star'}.svg`}), // ♥
elem('small', {}, 2), 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]); return rendernArticle([img, body]);
} }
function handleReply(evt, relay) { function handleReply(evt, relay) {
const article = feedDomMap[evt.tags[0][1]]; if (replyDomMap[evt.id]) {
if (article) { console.log('CALL ME already have reply in replyDomMap', evt, relay);
let replyContainer = article.querySelector('.mobx-replies'); return;
if (!replyContainer) {
replyContainer = elem('div', {className: 'mobx-replies'});
article.querySelector('.mbox-body').append(replyContainer);
}
replyContainer.append(createTextNote(evt, relay));
} }
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) => ( const sortEventCreatedAt = (created_at) => (
@ -189,11 +255,11 @@ function renderRecommendServer(evt, relay) {
]), ]),
` recommends server: ${evt.content}`, ` 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) { function rendernArticle(content, props = {}) {
const className = ['mbox', props?.className].join(' '); const className = props.className ? ['mbox', props?.className].join(' ') : 'mbox';
return elem('article', {...props, className}, content); return elem('article', {...props, className}, content);
} }
@ -292,8 +358,34 @@ feedContainer.addEventListener('click', (e) => {
input.focus(); input.focus();
return; 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 // send
const sendStatus = document.querySelector('#sendstatus'); const sendStatus = document.querySelector('#sendstatus');
const onSendError = err => { const onSendError = err => {
@ -302,7 +394,7 @@ const onSendError = err => {
}; };
const publish = document.querySelector('#publish'); const publish = document.querySelector('#publish');
publish.addEventListener('click', async () => { publish.addEventListener('click', async () => {
const pubkey = localStorage.getItem('pub_key'); // const pubkey = localStorage.getItem('pub_key');
const privatekey = localStorage.getItem('private_key'); const privatekey = localStorage.getItem('private_key');
if (!pubkey || !privatekey) { if (!pubkey || !privatekey) {
return onSendError(new Error('no pubkey/privatekey')); return onSendError(new Error('no pubkey/privatekey'));
@ -364,12 +456,13 @@ generateBtn.addEventListener('click', () => {
importBtn.addEventListener('click', () => { importBtn.addEventListener('click', () => {
const privatekey = privateKeyInput.value; const privatekey = privateKeyInput.value;
const pubkey = pubKeyInput.value; const pubkeyInput = pubKeyInput.value;
if (validKeys(privatekey, pubkey)) { if (validKeys(privatekey, pubkeyInput)) {
localStorage.setItem('private_key', privatekey); localStorage.setItem('private_key', privatekey);
localStorage.setItem('pub_key', pubkey); localStorage.setItem('pub_key', pubkeyInput);
statusMessage.textContent = 'stored private and public key locally!'; statusMessage.textContent = 'stored private and public key locally!';
statusMessage.hidden = false; statusMessage.hidden = false;
pubkey = pubkeyInput;
} }
}); });

Loading…
Cancel
Save