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