diff --git a/client/src/components/Home.tsx b/client/src/components/Home.tsx index 83a329c..bc1358f 100644 --- a/client/src/components/Home.tsx +++ b/client/src/components/Home.tsx @@ -21,17 +21,47 @@ const Home = () => { useEffect(() => { relay.on('connect', async () => { console.log(`connected to ${relay.url}`); + console.log(Math.floor(Date.now() - (30 * 24 * 60 * 60 * 1000))) + + // Pad the ID with leading zeros based on filterDifficulty + const paddedId = "0".padStart(filterDifficulty + 1, '0'); + console.log(paddedId); const eventList = await relay.list([ { - ids: ['0000'], + ids: ['00'], //prefix number of leading zeros from filterDifficulty kinds: [1], + //until: Date.now(), limit: 10, + //since: Math.floor(Date.now() - (30 * 24 * 60 * 60 * 1000)), // 24 hours ago }, ]); + // const socket = new WebSocket('wss://relay.damus.io'); + // socket.onopen = () => { + // console.log('WebSocket connected'); + // const subscription = [ + // 'REQ', + // 'POW-TEST', + // { + // ids: ["0000"], + // limit: 10, + // }, + // ]; + // socket.send(JSON.stringify(subscription)); + // }; + + // let i = 0; + // let start = Date.now(); + // socket.onmessage = event => { + // console.log(event.data); + // }; + // socket.onerror = error => { + // console.error('WebSocket error:', error); + // }; // Assuming eventList is of type Event[] setEvents(eventList); + console.log(eventList); }); relay.on('error', () => { @@ -47,7 +77,7 @@ const Home = () => {
{events.map((event, index) => ( - + ))}
diff --git a/client/src/components/PostCard/PostCard.tsx b/client/src/components/PostCard/PostCard.tsx index 5fe46ca..43e4446 100644 --- a/client/src/components/PostCard/PostCard.tsx +++ b/client/src/components/PostCard/PostCard.tsx @@ -1,11 +1,16 @@ import CardContainer from './CardContainer'; -const PostCard = ({ content }: { content: string }) => { +interface PostCardProps { + content: string; + time: Date; +} +const PostCard = ({ content, time }: PostCardProps) => { return ( <>
+ {time.toString()}
{content}
diff --git a/client/src/constants/settings.ts b/client/src/constants/settings.ts new file mode 100644 index 0000000..ada2d67 --- /dev/null +++ b/client/src/constants/settings.ts @@ -0,0 +1,4 @@ +let filterDifficulty: number = 0; +let difficulty: number = 16; +let timeout: number = 5; + diff --git a/client/src/func/events.ts b/client/src/func/events.ts new file mode 100644 index 0000000..f410469 --- /dev/null +++ b/client/src/func/events.ts @@ -0,0 +1,64 @@ +import {Event} from 'nostr-tools'; +import {zeroLeadingBitsCount} from '../utils/crypto'; + +export const isEvent = (evt?: T): evt is T => evt !== undefined; +export const isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention'; +export const isPTag = ([tag]: string[]) => tag === 'p'; +export const hasEventTag = (tag: string[]) => tag[0] === 'e'; +export const isNotNonceTag = ([tag]: string[]) => tag !== 'nonce'; + +/** + * validate proof-of-work of a nostr event per nip-13. + * the validation always requires difficulty commitment in the nonce tag. + * + * @param {EventObj} evt event to validate + * TODO: @param {number} targetDifficulty target proof-of-work difficulty + */ +export const validatePow = (evt: Event) => { + const tag = evt.tags.find(tag => tag[0] === 'nonce'); + if (!tag) { + return false; + } + const difficultyCommitment = Number(tag[2]); + if (!difficultyCommitment || Number.isNaN(difficultyCommitment)) { + return false; + } + return zeroLeadingBitsCount(evt.id) >= difficultyCommitment; +} + +export const sortByCreatedAt = (evt1: Event, evt2: Event) => { + if (evt1.created_at === evt2.created_at) { + // console.log('TODO: OMG exactly at the same time, figure out how to sort then', evt1, evt2); + } + return evt1.created_at > evt2.created_at ? -1 : 1; +}; + +export const sortEventCreatedAt = (created_at: number) => ( + {created_at: a}: Event, + {created_at: b}: Event, +) => ( + Math.abs(a - created_at) < Math.abs(b - created_at) ? -1 : 1 +); + +const isReply = ([tag, , , marker]: string[]) => tag === 'e' && marker !== 'mention'; + +/** + * find reply-to ID according to nip-10, find marked reply or root tag or + * fallback to positional (last) e tag or return null + * @param {event} evt + * @returns replyToID | null + */ +export const getReplyTo = (evt: Event): string | null => { + const eventTags = evt.tags.filter(isReply); + const withReplyMarker = eventTags.filter(([, , , marker]) => marker === 'reply'); + if (withReplyMarker.length === 1) { + return withReplyMarker[0][1]; + } + const withRootMarker = eventTags.filter(([, , , marker]) => marker === 'root'); + if (withReplyMarker.length === 0 && withRootMarker.length === 1) { + return withRootMarker[0][1]; + } + // fallback to deprecated positional 'e' tags (nip-10) + const lastTag = eventTags.at(-1); + return lastTag ? lastTag[1] : null; +}; diff --git a/client/src/func/main.ts b/client/src/func/main.ts new file mode 100644 index 0000000..3b7ad7f --- /dev/null +++ b/client/src/func/main.ts @@ -0,0 +1,188 @@ +import {Event, nip19} from 'nostr-tools'; +import {zeroLeadingBitsCount} from './utils/crypto'; +import {elem} from './utils/dom'; +import {bounce} from './utils/time'; +import {isWssUrl} from './utils/url'; +import {closeSettingsView, config, toggleSettingsView} from './settings'; +import {subGlobalFeed, subEventID, subNote} from './subscriptions' +import {getReplyTo, hasEventTag, isEvent, isMention, sortByCreatedAt, sortEventCreatedAt} from './events'; +import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view'; +import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes'; +import {createContact, createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui'; + +// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ + +type EventRelayMap = { + [eventId: string]: string[]; +}; +const eventRelayMap: EventRelayMap = {}; // eventId: [relay1, relay2] + +const renderNote = ( + evt: EventWithNip19, + i: number, + sortedFeeds: EventWithNip19[], +) => { + if (getViewElem(evt.id)) { // note already in view + return; + } + const article = createTextNote(evt, eventRelayMap[evt.id][0]); + if (i === 0) { + getViewContent().append(article); + } else { + getViewElem(sortedFeeds[i - 1].id).before(article); + } + setViewElem(evt.id, article); +}; + +const hasEnoughPOW = ( + [tag, , commitment]: string[], + eventId: string +) => { + return tag === 'nonce' && Number(commitment) >= config.filterDifficulty && zeroLeadingBitsCount(eventId) >= config.filterDifficulty; +}; + +const renderFeed = bounce(() => { + const view = getViewOptions(); + switch (view.type) { + case 'note': + textNoteList + .concat(replyList) // search id in notes and replies + .filter(note => note.id === view.id) + .forEach(renderNote); + break; + case 'feed': + const now = Math.floor(Date.now() * 0.001); + textNoteList + .filter(note => { + // dont render notes from the future + if (note.created_at > now) return false; + // if difficulty filter is configured dont render notes with too little pow + return !config.filterDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id)) + }) + .sort(sortByCreatedAt) + .reverse() + .forEach(renderNote); + break; + } +}, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s) + +const renderReply = (evt: EventWithNip19AndReplyTo) => { + const parent = getViewElem(evt.replyTo); + if (!parent || getViewElem(evt.id)) { + return; + } + let replyContainer = parent.querySelector('.mbox-replies'); + if (!replyContainer) { + replyContainer = elem('div', {className: 'mbox-replies'}); + parent.append(replyContainer); + parent.classList.add('mbox-has-replies'); + } + const reply = createTextNote(evt, eventRelayMap[evt.id][0]); + replyContainer.append(reply); + setViewElem(evt.id, reply); +}; + +const handleReply = (evt: EventWithNip19, relay: string) => { + if ( + getViewElem(evt.id) // already rendered probably received from another relay + || evt.tags.some(isMention) // ignore mentions for now + ) { + return; + } + const replyTo = getReplyTo(evt); + if (!replyTo) { + return; + } + const evtWithReplyTo = {replyTo, ...evt}; + replyList.push(evtWithReplyTo); + renderReply(evtWithReplyTo); +}; + +const handleTextNote = (evt: Event, relay: string) => { + if (evt.content.startsWith('vmess://') && !evt.content.includes(' ')) { + console.info('drop VMESS encrypted message'); + return; + } + if (eventRelayMap[evt.id]) { + eventRelayMap[evt.id] = [...(eventRelayMap[evt.id]), relay]; // TODO: remove eventRelayMap and just check for getViewElem? + } else { + eventRelayMap[evt.id] = [relay]; + const evtWithNip19 = { + nip19: { + note: nip19.noteEncode(evt.id), + npub: nip19.npubEncode(evt.pubkey), + }, + ...evt, + }; + if (evt.tags.some(hasEventTag)) { + handleReply(evtWithNip19, relay); + } else { + textNoteList.push(evtWithNip19); + } + } + if (!getViewElem(evt.id)) { + renderFeed(); + } +}; + +const rerenderFeed = () => { + clearView(); + renderFeed(); +}; +config.rerenderFeed = rerenderFeed; + +const onEvent = (evt: Event, relay: string) => { + switch (evt.kind) { + case 0: + handleTextNote(evt, relay); + break; + default: + // console.log(`TODO: add support for event kind ${evt.kind}`/*, evt*/) + } +}; + +// subscribe and change view +const route = (path: string) => { + if (path === '/') { + subGlobalFeed(onEvent); + view('/feed', {type: 'feed'}); + return; + } + if (path === '/feed') { + subGlobalFeed(onEvent); + view('/feed', {type: 'feed'}); + } else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) { + const {type, data} = nip19.decode(path.slice(1)); + if (typeof data !== 'string') { + console.warn('nip19 ProfilePointer, EventPointer and AddressPointer are not yet supported'); + return; + } + switch(type) { + case 'note': + subNote(data, onEvent); + view(path, {type: 'note', id: data}); + break; + default: + console.warn(`type ${type} not yet supported`); + } + renderFeed(); + } else if (path.length === 65) { + const eventID = path.slice(1); + subEventID(eventID, onEventDetails); + view(path, {type: 'event', id: eventID}); + } else { + console.warn('no support for ', path); + } +}; + +// onload +route(location.pathname); + +// only push a new entry if there is no history onload +if (!history.length) { + history.pushState({}, '', location.pathname); +} + +window.addEventListener('popstate', (event) => { + route(location.pathname); +}); diff --git a/client/src/func/relays.ts b/client/src/func/relays.ts new file mode 100644 index 0000000..a934344 --- /dev/null +++ b/client/src/func/relays.ts @@ -0,0 +1,111 @@ +import {Event, Filter, relayInit, Relay, Sub} from 'nostr-tools'; + +type SubCallback = ( + event: Readonly, + relay: Readonly, +) => void; + +type Subscribe = { + cb: SubCallback; + filter: Filter; + unsub?: boolean; +}; + +const subList: Array = []; +const currentSubList: Array = []; +const relayMap = new Map(); + +export const addRelay = async (url: string) => { + const relay = relayInit(url); + relay.on('connect', () => { + console.info(`connected to ${relay.url}`); + }); + relay.on('error', () => { + console.warn(`failed to connect to ${relay.url}`); + }); + try { + await relay.connect(); + currentSubList.forEach(({cb, filter}) => subscribe(cb, filter, relay)); + relayMap.set(url, relay); + } catch { + console.warn(`could not connect to ${url}`); + } +}; + +export const unsubscribe = (sub: Sub) => { + sub.unsub(); + subList.splice(subList.indexOf(sub), 1); +}; + +const subscribe = ( + cb: SubCallback, + filter: Filter, + relay: Relay, + unsub?: boolean +) => { + const sub = relay.sub([filter]); + subList.push(sub); + sub.on('event', (event: Event) => { + cb(event, relay.url); + }); + if (unsub) { + sub.on('eose', () => { + // console.log('eose', relay.url); + unsubscribe(sub); + }); + } + return sub; +}; + +export const sub = (obj: Subscribe) => { + currentSubList.push(obj); + relayMap.forEach((relay) => subscribe(obj.cb, obj.filter, relay, obj.unsub)); +}; + +export const subOnce = ( + obj: Subscribe & {relay: string} +) => { + const relay = relayMap.get(obj.relay); + if (relay) { + const sub = subscribe(obj.cb, obj.filter, relay); + sub.on('eose', () => { + // console.log('eose', obj.relay); + unsubscribe(sub); + }); + } +}; + +export const unsubAll = () => { + subList.forEach(unsubscribe); + currentSubList.length = 0; +}; + +type PublishCallback = ( + relay: string, + errorMessage?: string, +) => void; + +export const publish = ( + event: Event, + cb: PublishCallback, +) => { + relayMap.forEach((relay, url) => { + const pub = relay.publish(event); + pub.on('ok', () => { + console.info(`${relay.url} has accepted our event`); + cb(relay.url); + }); + pub.on('failed', (reason: any) => { + console.error(`failed to publish to ${relay.url}: ${reason}`); + cb(relay.url, reason); + }); + }); +}; + + +addRelay('wss://relay.snort.social'); +addRelay('wss://nostr.bitcoiner.social'); +addRelay('wss://nostr.mom'); +addRelay('wss://relay.nostr.bg'); +addRelay('wss://nos.lol'); +// addRelay('wss://relay.nostr.ch'); diff --git a/client/src/func/settings.ts b/client/src/func/settings.ts new file mode 100644 index 0000000..a26504c --- /dev/null +++ b/client/src/func/settings.ts @@ -0,0 +1,236 @@ +import {generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; +import {updateElemHeight} from './utils/dom'; +import {powEvent} from './system'; +import {publish} from './relays'; + +const settingsView = document.querySelector('#settings') as HTMLElement; + +export const closeSettingsView = () => settingsView.hidden = true; + +export const toggleSettingsView = () => settingsView.hidden = !settingsView.hidden; + +let pubkey: string = ''; + +const loadOrGenerateKeys = () => { + const storedPubKey = localStorage.getItem('pub_key'); + if (storedPubKey) { + return storedPubKey; + } + const privatekey = generatePrivateKey(); + const pubkey = getPublicKey(privatekey); + localStorage.setItem('private_key', privatekey); + localStorage.setItem('pub_key', pubkey); + return pubkey; +}; + +let filterDifficulty: number = 0; +let difficulty: number = 16; +let timeout: number = 5; +let rerenderFeed: (() => void) | undefined; + +/** + * global config object + * config.pubkey, if not set loaded from localStorage or generate a new key + */ +export const config = { + get pubkey() { + if (!pubkey) { + pubkey = loadOrGenerateKeys(); + } + return pubkey; + }, + set pubkey(value) { + console.info(`pubkey was set to ${value}`); + pubkey = value; + }, + get filterDifficulty() { + return filterDifficulty; + }, + get difficulty() { + return difficulty; + }, + get timeout() { + return timeout; + }, + set rerenderFeed(value: () => void) { + rerenderFeed = value; + } +}; + +const getNumberFromStorage = ( + item: string, + fallback: number, +) => { + const stored = localStorage.getItem(item); + if (!stored) { + return fallback; + } + return Number(stored); +}; + +// filter difficulty +const filterDifficultyInput = document.querySelector('#filterDifficulty') as HTMLInputElement; +const filterDifficultyDisplay = document.querySelector('[data-display="filter_difficulty"]') as HTMLElement; +filterDifficultyInput.addEventListener('input', (e) => { + localStorage.setItem('filter_difficulty', filterDifficultyInput.value); + filterDifficulty = filterDifficultyInput.valueAsNumber; + filterDifficultyDisplay.textContent = filterDifficultyInput.value; + rerenderFeed && rerenderFeed(); +}); +filterDifficulty = getNumberFromStorage('filter_difficulty', 0); +filterDifficultyInput.valueAsNumber = filterDifficulty; +filterDifficultyDisplay.textContent = filterDifficultyInput.value; + +// mining difficulty target +const miningTargetInput = document.querySelector('#miningTarget') as HTMLInputElement; +miningTargetInput.addEventListener('input', (e) => { + localStorage.setItem('mining_target', miningTargetInput.value); + difficulty = miningTargetInput.valueAsNumber; +}); +// arbitrary difficulty default, still experimenting. +difficulty = getNumberFromStorage('mining_target', 16); +miningTargetInput.valueAsNumber = difficulty; + +// mining timeout +const miningTimeoutInput = document.querySelector('#miningTimeout') as HTMLInputElement; +miningTimeoutInput.addEventListener('input', (e) => { + localStorage.setItem('mining_timeout', miningTimeoutInput.value); + timeout = miningTimeoutInput.valueAsNumber; +}); +timeout = getNumberFromStorage('mining_timeout', 5); +miningTimeoutInput.valueAsNumber = timeout; + + +// settings +const settingsForm = document.querySelector('form[name="settings"]') as HTMLFormElement; +const privateKeyInput = settingsForm.querySelector('#privatekey') as HTMLInputElement; +const pubKeyInput = settingsForm.querySelector('#pubkey') as HTMLInputElement; +const statusMessage = settingsForm.querySelector('#keystatus') as HTMLElement; +const generateBtn = settingsForm.querySelector('button[name="generate"]') as HTMLButtonElement; +const importBtn = settingsForm.querySelector('button[name="import"]') as HTMLButtonElement; +const privateTgl = settingsForm.querySelector('button[name="privatekey-toggle"]') as HTMLButtonElement; + +const validKeys = ( + privatekey: string, + pubkey: string, +) => { + try { + if (getPublicKey(privatekey) === pubkey) { + statusMessage.hidden = true; + statusMessage.textContent = 'public-key corresponds to private-key'; + importBtn.removeAttribute('disabled'); + return true; + } else { + statusMessage.textContent = 'private-key does not correspond to public-key!' + } + } catch (e) { + statusMessage.textContent = `not a valid private-key: ${e.message || e}`; + } + statusMessage.hidden = false; + importBtn.disabled = true; + return false; +}; + +generateBtn.addEventListener('click', () => { + const privatekey = generatePrivateKey(); + const pubkey = getPublicKey(privatekey); + if (validKeys(privatekey, pubkey)) { + privateKeyInput.value = privatekey; + pubKeyInput.value = pubkey; + statusMessage.textContent = 'private-key created!'; + statusMessage.hidden = false; + } +}); + +importBtn.addEventListener('click', () => { + const privatekey = privateKeyInput.value; + const pubkeyInput = pubKeyInput.value; + if (validKeys(privatekey, pubkeyInput)) { + localStorage.setItem('private_key', privatekey); + localStorage.setItem('pub_key', pubkeyInput); + statusMessage.textContent = 'stored private and public key locally!'; + statusMessage.hidden = false; + config.pubkey = pubkeyInput; + } +}); + +settingsForm.addEventListener('input', () => validKeys(privateKeyInput.value, pubKeyInput.value)); + +privateKeyInput.addEventListener('paste', (event) => { + if (pubKeyInput.value || !event.clipboardData) { + return; + } + if (privateKeyInput.value === '' || ( // either privatekey field is empty + privateKeyInput.selectionStart === 0 // or the whole text is selected and replaced with the clipboard + && privateKeyInput.selectionEnd === privateKeyInput.value.length + )) { // only generate the pubkey if no data other than the text from clipboard will be used + try { + pubKeyInput.value = getPublicKey(event.clipboardData.getData('text')); + } catch(err) {} // settings form will call validKeys on input and display the error + } +}); + +privateTgl.addEventListener('click', () => { + privateKeyInput.type = privateKeyInput.type === 'text' ? 'password' : 'text'; +}); + +privateKeyInput.value = localStorage.getItem('private_key') || ''; +pubKeyInput.value = localStorage.getItem('pub_key') || ''; + +// profile +const profileForm = document.querySelector('form[name="profile"]') as HTMLFormElement; +const profileSubmit = profileForm.querySelector('button[type="submit"]') as HTMLButtonElement; +const profileStatus = document.querySelector('#profilestatus') as HTMLElement; + +profileForm.addEventListener('input', (e) => { + if (e.target instanceof HTMLElement) { + if (e.target?.nodeName === 'TEXTAREA') { + updateElemHeight(e.target as HTMLTextAreaElement); + } + } + const form = new FormData(profileForm); + const name = form.get('name'); + const about = form.get('about'); + const picture = form.get('picture'); + profileSubmit.disabled = !(name || about || picture); +}); + +profileForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const form = new FormData(profileForm); + const newProfile = await powEvent({ + kind: 0, + pubkey: config.pubkey, + content: JSON.stringify(Object.fromEntries(form)), + tags: [], + created_at: Math.floor(Date.now() * 0.001) + }, { + difficulty: config.difficulty, + statusElem: profileStatus, + timeout: config.timeout, + }).catch(console.warn); + if (!newProfile) { + profileStatus.textContent = 'publishing profile data canceled'; + profileStatus.hidden = false; + return; + } + const privatekey = localStorage.getItem('private_key'); + if (!privatekey) { + profileStatus.textContent = 'no private key to sign'; + profileStatus.hidden = false; + return; + } + const sig = signEvent(newProfile, privatekey); + // TODO: validateEvent + if (sig) { + publish({...newProfile, sig}, (relay, error) => { + if (error) { + return console.error(error, relay); + } + console.info(`publish request sent to ${relay}`); + profileStatus.textContent = 'profile successfully published'; + profileStatus.hidden = false; + profileSubmit.disabled = true; + }); + } +}); diff --git a/client/src/func/subscriptions.ts b/client/src/func/subscriptions.ts new file mode 100644 index 0000000..d47777f --- /dev/null +++ b/client/src/func/subscriptions.ts @@ -0,0 +1,136 @@ +import {Event} from 'nostr-tools'; +import {getReplyTo, hasEventTag, isMention, isPTag} from './events'; +import {config} from './settings'; +import {sub, subOnce, unsubAll} from './relays'; + +type SubCallback = ( + event: Event, + relay: string, +) => void; + +/** subscribe to global feed */ +export const subGlobalFeed = (onEvent: SubCallback) => { + console.info('subscribe to global feed'); + unsubAll(); + const now = Math.floor(Date.now() * 0.001); + const pubkeys = new Set(); + const notes = new Set(); + const prefix = Math.floor(config.filterDifficulty / 4); // 4 bits in each '0' character + sub({ // get past events + cb: (evt, relay) => { + pubkeys.add(evt.pubkey); + notes.add(evt.id); + onEvent(evt, relay); + }, + filter: { + ...(prefix && {ids: ['0'.repeat(prefix)]}), + kinds: [1], + until: now, + ...(!prefix && {since: Math.floor(now - (24 * 60 * 60))}), + limit: 100, + }, + unsub: true + }); + + setTimeout(() => { + // get profile info + sub({ + cb: onEvent, + filter: { + authors: Array.from(pubkeys), + kinds: [0], + limit: pubkeys.size, + }, + unsub: true, + }); + pubkeys.clear(); + + notes.clear(); + }, 2000); + + // subscribe to future notes, reactions and profile updates + sub({ + cb: (evt, relay) => { + onEvent(evt, relay); + if ( + evt.kind !== 1 + || pubkeys.has(evt.pubkey) + ) { + return; + } + }, + filter: { + ...(prefix && {ids: ['0'.repeat(prefix)]}), + kinds: [1], + since: now, + }, + }); +}; + +/** subscribe to a note id (nip-19) */ +export const subNote = ( + eventId: string, + onEvent: SubCallback, +) => { + unsubAll(); + sub({ + cb: onEvent, + filter: { + ids: [eventId], + kinds: [1], + limit: 1, + }, + unsub: true, + }); + + const replies = new Set(); + + const onReply = (evt: Event, relay: string) => { + replies.add(evt.id) + onEvent(evt, relay); + unsubAll(); + sub({ + cb: onEvent, + filter: { + '#e': Array.from(replies), + kinds: [1, 7], + }, + unsub: true, + }); + }; + + replies.add(eventId); + setTimeout(() => { + sub({ + cb: onReply, + filter: { + '#e': [eventId], + kinds: [1, 7], + }, + unsub: true, // TODO: probably keep this subscription also after onReply/unsubAll + }); + }, 200); +}; + +export const subEventID = ( + id: string, + onEvent: SubCallback, +) => { + unsubAll(); + sub({ + cb: onEvent, + filter: { + ids: [id], + limit: 1, + }, + unsub: true, + }); + sub({ + cb: onEvent, + filter: { + authors: [id], + limit: 200, + }, + unsub: true, + }); +}; diff --git a/client/src/func/system.ts b/client/src/func/system.ts new file mode 100644 index 0000000..1bb2b96 --- /dev/null +++ b/client/src/func/system.ts @@ -0,0 +1,124 @@ +import {Event, getEventHash, UnsignedEvent} from 'nostr-tools'; +import {elem, lockScroll, unlockScroll} from './utils/dom'; + +const errorOverlay = document.querySelector('section#errorOverlay') as HTMLElement; + +type PromptErrorOptions = { + onCancel?: () => void; + onRetry?: () => void; +}; + +/** + * Creates an error overlay, currently with hardcoded POW related message, this could be come a generic prompt + * @param error message + * @param options {onRetry, onCancel} callbacks + */ +const promptError = ( + error: string, + options: PromptErrorOptions, +) => { + const {onCancel, onRetry} = options; + lockScroll(); + errorOverlay.replaceChildren( + elem('h1', {className: 'error-title'}, error), + elem('p', {}, 'time ran out finding a proof with the desired mining difficulty. either try again, lower the mining difficulty or increase the timeout in profile settings.'), + elem('div', {className: 'buttons'}, [ + onCancel ? elem('button', {data: {action: 'close'}}, 'close') : '', + onRetry ? elem('button', {data: {action: 'again'}}, 'try again') : '', + ]), + ); + const handleOverlayClick = (e: MouseEvent) => { + if (e.target instanceof Element) { + const button = e.target.closest('button'); + if (button) { + switch(button.dataset.action) { + case 'close': + onCancel && onCancel(); + break; + case 'again': + onRetry && onRetry(); + break; + } + errorOverlay.removeEventListener('click', handleOverlayClick); + errorOverlay.hidden = true; + unlockScroll(); + } + } + }; + errorOverlay.addEventListener('click', handleOverlayClick); + errorOverlay.hidden = false; +} + +type PowEventOptions = { + difficulty: number; + statusElem: HTMLElement; + timeout: number; +}; + +type WorkerResponse = { + error: string; + event: Event; +}; + +type HashedEvent = UnsignedEvent & { + id: string; +}; + +/** + * run proof of work in a worker until at least the specified difficulty. + * if succcessful, the returned event contains the 'nonce' tag + * and the updated created_at timestamp. + * + * powEvent returns a rejected promise if the funtion runs for longer than timeout. + * a zero timeout makes mineEvent run without a time limit. + * a zero mining target just resolves the promise without trying to find a 'nonce'. + */ +export const powEvent = ( + evt: UnsignedEvent, + options: PowEventOptions +): Promise => { + const {difficulty, statusElem, timeout} = options; + if (difficulty === 0) { + return Promise.resolve({ + ...evt, + id: getEventHash(evt), + }); + } + const cancelBtn = elem('button', {className: 'btn-inline'}, [elem('small', {}, 'cancel')]); + statusElem.replaceChildren('working…', cancelBtn); + statusElem.hidden = false; + return new Promise((resolve, reject) => { + const worker = new Worker('/worker.js'); + + const onCancel = () => { + worker.terminate(); + reject(`mining kind ${evt.kind} event canceled`); + }; + cancelBtn.addEventListener('click', onCancel); + + worker.onmessage = (msg: MessageEvent) => { + worker.terminate(); + cancelBtn.removeEventListener('click', onCancel); + if (msg.data.error) { + promptError(msg.data.error, { + onCancel: () => reject(`mining kind ${evt.kind} event canceled`), + onRetry: async () => { + const result = await powEvent(evt, {difficulty, statusElem, timeout}).catch(console.warn); + resolve(result); + } + }) + } else { + resolve(msg.data.event); + } + }; + + worker.onerror = (err) => { + worker.terminate(); + // promptError(msg.data.error, {}); + cancelBtn.removeEventListener('click', onCancel); + reject(err); + }; + + worker.postMessage({event: evt, difficulty, timeout}); + }); +}; diff --git a/client/src/func/worker.js b/client/src/func/worker.js new file mode 100644 index 0000000..c9007b2 --- /dev/null +++ b/client/src/func/worker.js @@ -0,0 +1,42 @@ +import {getEventHash} from 'nostr-tools'; +import {zeroLeadingBitsCount} from './utils/crypto'; + +const mine = (event, difficulty, timeout = 5) => { + const max = 256; // arbitrary + if (!Number.isInteger(difficulty) || difficulty < 0 || difficulty > max) { + throw new Error(`difficulty must be an integer between 0 and ${max}`); + } + // continue with mining + let n = BigInt(0); + event.tags.unshift(['nonce', n.toString(), `${difficulty}`]); + + const until = Math.floor(Date.now() * 0.001) + timeout; + console.time('pow'); + while (true) { + const now = Math.floor(Date.now() * 0.001); + if (timeout !== 0 && (now > until)) { + console.timeEnd('pow'); + throw 'timeout'; + } + if (now !== event.created_at) { + event.created_at = now; + // n = BigInt(0); // could reset nonce as we have a new timestamp + } + event.tags[0][1] = (++n).toString(); + const id = getEventHash(event); + if (zeroLeadingBitsCount(id) === difficulty) { + console.timeEnd('pow'); + return {id, ...event}; + } + } +}; + +addEventListener('message', (msg) => { + const {difficulty, event, timeout} = msg.data; + try { + const minedEvent = mine(event, difficulty, timeout); + postMessage({event: minedEvent}); + } catch (err) { + postMessage({error: err}); + } +}); \ No newline at end of file diff --git a/client/src/utils/crypto.ts b/client/src/utils/crypto.ts new file mode 100644 index 0000000..8ea6b85 --- /dev/null +++ b/client/src/utils/crypto.ts @@ -0,0 +1,25 @@ +/** + * evaluate the difficulty of hex32 according to nip-13. + * @param hex32 a string of 64 chars - 32 bytes in hex representation + */ +export const zeroLeadingBitsCount = (hex32: string) => { + let count = 0; + for (let i = 0; i < 64; i += 2) { + const hexbyte = hex32.slice(i, i + 2); // grab next byte + if (hexbyte === '00') { + count += 8; + continue; + } + // reached non-zero byte; count number of 0 bits in hexbyte + const bits = parseInt(hexbyte, 16).toString(2).padStart(8, '0'); + for (let b = 0; b < 8; b++) { + if (bits[b] === '1' ) { + break; // reached non-zero bit; stop + } + count += 1; + } + break; + } + return count; + }; + \ No newline at end of file