mirror of
https://github.com/smolgrrr/TAO.git
synced 2024-09-20 01:11:25 +00:00
Merge branch 'main' of https://github.com/smolgrrr/TAO
This commit is contained in:
commit
69f8dc9e2e
@ -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 = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
|
||||
<NewThreadCard />
|
||||
{events.map((event, index) => (
|
||||
<PostCard key={index} content={event.content} />
|
||||
<PostCard key={index} content={event.content} time={new Date(event.created_at * 1000)} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
@ -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 (
|
||||
<>
|
||||
<CardContainer>
|
||||
<div className="flex flex-col">
|
||||
{time.toString()}
|
||||
<div className="mr-2 flex flex-col break-words">
|
||||
{content}
|
||||
</div>
|
||||
|
4
client/src/constants/settings.ts
Normal file
4
client/src/constants/settings.ts
Normal file
@ -0,0 +1,4 @@
|
||||
let filterDifficulty: number = 0;
|
||||
let difficulty: number = 16;
|
||||
let timeout: number = 5;
|
||||
|
64
client/src/func/events.ts
Normal file
64
client/src/func/events.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import {Event} from 'nostr-tools';
|
||||
import {zeroLeadingBitsCount} from '../utils/crypto';
|
||||
|
||||
export const isEvent = <T>(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;
|
||||
};
|
188
client/src/func/main.ts
Normal file
188
client/src/func/main.ts
Normal file
@ -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);
|
||||
});
|
111
client/src/func/relays.ts
Normal file
111
client/src/func/relays.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import {Event, Filter, relayInit, Relay, Sub} from 'nostr-tools';
|
||||
|
||||
type SubCallback = (
|
||||
event: Readonly<Event>,
|
||||
relay: Readonly<string>,
|
||||
) => void;
|
||||
|
||||
type Subscribe = {
|
||||
cb: SubCallback;
|
||||
filter: Filter;
|
||||
unsub?: boolean;
|
||||
};
|
||||
|
||||
const subList: Array<Sub> = [];
|
||||
const currentSubList: Array<Subscribe> = [];
|
||||
const relayMap = new Map<string, Relay>();
|
||||
|
||||
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');
|
236
client/src/func/settings.ts
Normal file
236
client/src/func/settings.ts
Normal file
@ -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;
|
||||
});
|
||||
}
|
||||
});
|
136
client/src/func/subscriptions.ts
Normal file
136
client/src/func/subscriptions.ts
Normal file
@ -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<string>();
|
||||
const notes = new Set<string>();
|
||||
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<string>();
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
124
client/src/func/system.ts
Normal file
124
client/src/func/system.ts
Normal file
@ -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<HashedEvent | void> => {
|
||||
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<WorkerResponse>) => {
|
||||
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});
|
||||
});
|
||||
};
|
42
client/src/func/worker.js
Normal file
42
client/src/func/worker.js
Normal file
@ -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});
|
||||
}
|
||||
});
|
25
client/src/utils/crypto.ts
Normal file
25
client/src/utils/crypto.ts
Normal file
@ -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;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user