diff --git a/src/main.js b/src/main.js index d7551da..8c6d2c7 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,4 @@ -import {nip19, signEvent} from 'nostr-tools'; +import {nip19} from 'nostr-tools'; import {zeroLeadingBitsCount} from './utils/crypto'; import {elem, elemCanvas, elemShrink, parseTextContent, updateElemHeight} from './utils/dom'; import {bounce, dateTime, formatTime} from './utils/time'; @@ -9,6 +9,7 @@ import {publish} from './relays'; import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events'; import {clearView, getViewContent, getViewElem, setViewElem, view} from './view'; import {closeSettingsView, config, toggleSettingsView} from './settings'; +import {getReactions, getReactionContents, handleReaction, handleUpvote} from './reactions'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ function onEvent(evt, relay) { @@ -117,42 +118,6 @@ function renderReply(evt, relay) { setViewElem(evt.id, reply); } -const reactionMap = {}; - -const getReactionList = (id) => { - return reactionMap[id]?.map(({content}) => content) || []; -}; - -function handleReaction(evt, relay) { - // last id is the note that is being reacted to https://github.com/nostr-protocol/nips/blob/master/25.md - const lastEventTag = evt.tags.filter(hasEventTag).at(-1); - if (!lastEventTag || !evt.content.length) { - // ignore reactions with no content - return; - } - const [, eventId] = lastEventTag; - 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 = getViewElem(eventId); - if (article) { - const button = article.querySelector('button[name="star"]'); - const reactions = button.querySelector('[data-reactions]'); - reactions.textContent = reactionMap[eventId].length; - if (evt.pubkey === config.pubkey) { - const star = button.querySelector('img[src*="star"]'); - star?.setAttribute('src', '/assets/star-fill.svg'); - star?.setAttribute('title', getReactionList(eventId).join(' ')); - } - } -} - const restoredReplyTo = localStorage.getItem('reply_to'); config.rerenderFeed = () => { @@ -232,8 +197,8 @@ function createTextNote(evt, relay) { const replies = replyList.filter(({replyTo}) => replyTo === evt.id); // const isLongContent = evt.content.trimRight().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 === config.pubkey); + const reactions = getReactions(evt.id); + const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey); const replyFeed = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : []; const [content, {firstLink}] = parseTextContent(evt.content); const body = elem('div', {className: 'mbox-body'}, [ @@ -260,9 +225,9 @@ function createTextNote(evt, relay) { alt: didReact ? '✭' : '✩', // ♥ height: 24, width: 24, src: `/assets/${didReact ? 'star-fill' : 'star'}.svg`, - title: getReactionList(evt.id).join(' '), + title: getReactionContents(evt.id).join(' '), }), - elem('small', {data: {reactions: ''}}, hasReactions ? reactionMap[evt.id].length : ''), + elem('small', {data: {reactions: ''}}, reactions.length || ''), ]), ]), ]); @@ -456,49 +421,6 @@ function appendReplyForm(el) { requestAnimationFrame(() => writeInput.focus()); } -async function upvote(eventId, eventPubkey) { - const note = replyList.find(r => r.id === eventId) || textNoteList.find(n => n.id === (eventId)); - const tags = [ - ...note.tags - .filter(tag => ['e', 'p'].includes(tag[0])) // take e and p tags from event - .map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields - ['e', eventId], ['p', eventPubkey], // last e and p tag is the id and pubkey of the note being reacted to (nip-25) - ]; - const article = getViewElem(eventId); - const reactionBtn = article.querySelector('[name="star"]'); - const statusElem = article.querySelector('[data-reactions]'); - reactionBtn.disabled = true; - const newReaction = await powEvent({ - kind: 7, - pubkey: config.pubkey, // TODO: lib could check that this is the pubkey of the key to sign with - content: '+', - tags, - created_at: Math.floor(Date.now() * 0.001), - }, { - difficulty: config.difficulty, - statusElem, - timeout: config.timeout, - }).catch(console.warn); - if (!newReaction) { - statusElem.textContent = reactionMap[eventId]?.length; - reactionBtn.disabled = false; - return; - } - const privatekey = localStorage.getItem('private_key'); - const sig = signEvent(newReaction, privatekey); - // TODO: validateEvent - if (sig) { - statusElem.textContent = 'publishing…'; - publish({...newReaction, sig}, (relay, error) => { - if (error) { - return console.error(error, relay); - } - console.info(`event published by ${relay}`); - }); - reactionBtn.disabled = false; - } -} - // send const sendStatus = document.querySelector('#sendstatus'); const onSendError = err => sendStatus.textContent = err.message; @@ -640,7 +562,8 @@ document.body.addEventListener('click', (e) => { localStorage.setItem('reply_to', id); break; case 'star': - upvote(id, pubkey); + const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); + handleUpvote(note); break; case 'settings': toggleSettingsView(); diff --git a/src/reactions.ts b/src/reactions.ts new file mode 100644 index 0000000..b63d846 --- /dev/null +++ b/src/reactions.ts @@ -0,0 +1,105 @@ +import {Event, signEvent, UnsignedEvent} from 'nostr-tools'; +import {powEvent} from './system'; +import {publish} from './relays'; +import {hasEventTag} from './events'; +import {getViewElem} from './view'; +import {config} from './settings'; + +type ReactionMap = { + [eventId: string]: Array +}; + +const reactionMap: ReactionMap = {}; + +export const getReactions = (eventId: string) => reactionMap[eventId] || []; + +export const getReactionContents = (eventId: string) => { + return reactionMap[eventId]?.map(({content}) => content) || []; +}; + +export const handleReaction = ( + evt: Event, + relay: string, +) => { + // last id is the note that is being reacted to https://github.com/nostr-protocol/nips/blob/master/25.md + const lastEventTag = evt.tags.filter(hasEventTag).at(-1); + if (!lastEventTag || !evt.content.length) { + // ignore reactions with no content + return; + } + const [, eventId] = lastEventTag; + 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 = getViewElem(eventId); + if (article) { + const button = article.querySelector('button[name="star"]') as HTMLButtonElement; + const reactions = button.querySelector('[data-reactions]') as HTMLElement; + reactions.textContent = `${reactionMap[eventId].length || ''}`; + if (evt.pubkey === config.pubkey) { + const star = button.querySelector('img[src*="star"]'); + star?.setAttribute('src', '/assets/star-fill.svg'); + star?.setAttribute('title', getReactionContents(eventId).join(' ')); + } + } +}; + +const upvote = async ( + eventId: string, + evt: UnsignedEvent, +) => { + const article = getViewElem(eventId); + const reactionBtn = article.querySelector('button[name="star"]') as HTMLButtonElement; + const statusElem = article.querySelector('[data-reactions]') as HTMLElement; + reactionBtn.disabled = true; + const newReaction = await powEvent(evt, { + difficulty: config.difficulty, + statusElem, + timeout: config.timeout, + }).catch(console.warn); + if (!newReaction) { + statusElem.textContent = `${getReactions(eventId)?.length}`; + reactionBtn.disabled = false; + return; + } + const privatekey = localStorage.getItem('private_key'); + if (!privatekey) { + statusElem.textContent = 'no private key to sign'; + statusElem.hidden = false; + return; + } + const sig = signEvent(newReaction, privatekey); + // TODO: validateEvent + if (sig) { + statusElem.textContent = 'publishing…'; + publish({...newReaction, sig}, (relay, error) => { + if (error) { + return console.error(error, relay); + } + console.info(`event published by ${relay}`); + }); + reactionBtn.disabled = false; + } +}; + +export const handleUpvote = (evt: Event) => { + const tags = [ + ...evt.tags + .filter(tag => ['e', 'p'].includes(tag[0])) // take e and p tags from event + .map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields, TODO: use relay? + ['e', evt.id], ['p', evt.pubkey], // last e and p tag is the id and pubkey of the note being reacted to (nip-25) + ]; + upvote(evt.id, { + kind: 7, + pubkey: config.pubkey, + content: '+', + tags, + created_at: Math.floor(Date.now() * 0.001), + }); +};