forked from nostr/nostrweb
refactor: type events.ts, url.ts and crypto.ts
parent
fa97027321
commit
2d46687e12
|
@ -0,0 +1,61 @@
|
||||||
|
import {Event} from 'nostr-tools';
|
||||||
|
import {zeroLeadingBitsCount} from './utils';
|
||||||
|
|
||||||
|
export const isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention';
|
||||||
|
export const hasEventTag = (tag: string[]) => tag[0] === 'e';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
106
src/main.js
106
src/main.js
|
@ -1,9 +1,9 @@
|
||||||
import {generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent} from 'nostr-tools';
|
import {generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent} from 'nostr-tools';
|
||||||
import {sub24hFeed, subNote, subProfile} from './subscriptions'
|
import {sub24hFeed, subNote, subProfile} from './subscriptions'
|
||||||
import {publish} from './relays';
|
import {publish} from './relays';
|
||||||
|
import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt, validatePow} from './events';
|
||||||
import {clearView, getViewContent, getViewElem, setViewElem, view} from './view';
|
import {clearView, getViewContent, getViewElem, setViewElem, view} from './view';
|
||||||
import {zeroLeadingBitsCount} from './cryptoutils.js';
|
import {bounce, dateTime, elem, formatTime, getHost, getNoxyUrl, isWssUrl, parseTextContent, zeroLeadingBitsCount} from './utils';
|
||||||
import {bounce, dateTime, elem, formatTime, parseTextContent} from './utils';
|
|
||||||
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
|
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
|
||||||
|
|
||||||
function onEvent(evt, relay) {
|
function onEvent(evt, relay) {
|
||||||
|
@ -39,12 +39,6 @@ let pubkey = localStorage.getItem('pub_key') || (() => {
|
||||||
const textNoteList = []; // could use indexDB
|
const textNoteList = []; // could use indexDB
|
||||||
const eventRelayMap = {}; // eventId: [relay1, relay2]
|
const eventRelayMap = {}; // eventId: [relay1, relay2]
|
||||||
|
|
||||||
const hasEventTag = tag => tag[0] === 'e';
|
|
||||||
const isReply = ([tag, , , marker]) => tag === 'e' && marker !== 'mention';
|
|
||||||
const isMention = ([tag, , , marker]) => tag === 'e' && marker === 'mention';
|
|
||||||
const hasEnoughPOW = ([tag, , commitment]) => {
|
|
||||||
return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(note.id) >= fitlerDifficulty;
|
|
||||||
};
|
|
||||||
const renderNote = (evt, i, sortedFeeds) => {
|
const renderNote = (evt, i, sortedFeeds) => {
|
||||||
if (getViewElem(evt.id)) { // note already in view
|
if (getViewElem(evt.id)) { // note already in view
|
||||||
return;
|
return;
|
||||||
|
@ -58,13 +52,17 @@ const renderNote = (evt, i, sortedFeeds) => {
|
||||||
setViewElem(evt.id, article);
|
setViewElem(evt.id, article);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasEnoughPOW = ([tag, , commitment], eventId) => {
|
||||||
|
return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(eventId) >= fitlerDifficulty;
|
||||||
|
};
|
||||||
|
|
||||||
const renderFeed = bounce(() => {
|
const renderFeed = bounce(() => {
|
||||||
const now = Math.floor(Date.now() * 0.001);
|
const now = Math.floor(Date.now() * 0.001);
|
||||||
textNoteList
|
textNoteList
|
||||||
// dont render notes from the future
|
// dont render notes from the future
|
||||||
.filter(note => note.created_at <= now)
|
.filter(note => note.created_at <= now)
|
||||||
// if difficulty filter is configured dont render notes with too little pow
|
// if difficulty filter is configured dont render notes with too little pow
|
||||||
.filter(note => !fitlerDifficulty || note.tags.some(hasEnoughPOW))
|
.filter(note => !fitlerDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id)))
|
||||||
.sort(sortByCreatedAt)
|
.sort(sortByCreatedAt)
|
||||||
.reverse()
|
.reverse()
|
||||||
.forEach(renderNote);
|
.forEach(renderNote);
|
||||||
|
@ -161,13 +159,6 @@ function handleReaction(evt, relay) {
|
||||||
|
|
||||||
const restoredReplyTo = localStorage.getItem('reply_to');
|
const restoredReplyTo = localStorage.getItem('reply_to');
|
||||||
|
|
||||||
const sortByCreatedAt = (evt1, evt2) => {
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
function rerenderFeed() {
|
function rerenderFeed() {
|
||||||
clearView();
|
clearView();
|
||||||
renderFeed();
|
renderFeed();
|
||||||
|
@ -179,17 +170,6 @@ setInterval(() => {
|
||||||
});
|
});
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
const getNoxyUrl = (type, url, id, relay) => {
|
|
||||||
if (!isHttpUrl(url)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const link = new URL(`https://noxy.nostr.ch/${type}`);
|
|
||||||
link.searchParams.set('id', id);
|
|
||||||
link.searchParams.set('relay', relay);
|
|
||||||
link.searchParams.set('url', url);
|
|
||||||
return link;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchQue = [];
|
const fetchQue = [];
|
||||||
let fetchPending;
|
let fetchPending;
|
||||||
const fetchNext = (href, id, relay) => {
|
const fetchNext = (href, id, relay) => {
|
||||||
|
@ -300,21 +280,6 @@ function createTextNote(evt, relay) {
|
||||||
], {data: {id: evt.id, pubkey: evt.pubkey, relay}});
|
], {data: {id: evt.id, pubkey: evt.pubkey, relay}});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortEventCreatedAt = (created_at) => (
|
|
||||||
{created_at: a},
|
|
||||||
{created_at: b},
|
|
||||||
) => (
|
|
||||||
Math.abs(a - created_at) < Math.abs(b - created_at) ? -1 : 1
|
|
||||||
);
|
|
||||||
|
|
||||||
function isWssUrl(string) {
|
|
||||||
try {
|
|
||||||
return 'wss:' === new URL(string).protocol;
|
|
||||||
} catch (err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRecommendServer(evt, relay) {
|
function handleRecommendServer(evt, relay) {
|
||||||
if (getViewElem(evt.id) || !isWssUrl(evt.content)) {
|
if (getViewElem(evt.id) || !isWssUrl(evt.content)) {
|
||||||
return;
|
return;
|
||||||
|
@ -326,7 +291,7 @@ function handleRecommendServer(evt, relay) {
|
||||||
const closestTextNotes = textNoteList
|
const closestTextNotes = textNoteList
|
||||||
.filter(note => !fitlerDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && commitment >= fitlerDifficulty))
|
.filter(note => !fitlerDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && commitment >= fitlerDifficulty))
|
||||||
.sort(sortEventCreatedAt(evt.created_at));
|
.sort(sortEventCreatedAt(evt.created_at));
|
||||||
getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed
|
getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed
|
||||||
}
|
}
|
||||||
setViewElem(evt.id, art);
|
setViewElem(evt.id, art);
|
||||||
}
|
}
|
||||||
|
@ -450,22 +415,6 @@ function setMetadata(evt, relay, content) {
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHttpUrl(string) {
|
|
||||||
try {
|
|
||||||
return ['http:', 'https:'].includes(new URL(string).protocol);
|
|
||||||
} catch (err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getHost = (url) => {
|
|
||||||
try {
|
|
||||||
return new URL(url).host;
|
|
||||||
} catch(err) {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const elemCanvas = (text) => {
|
const elemCanvas = (text) => {
|
||||||
const canvas = elem('canvas', {height: 80, width: 80, data: {pubkey: text}});
|
const canvas = elem('canvas', {height: 80, width: 80, data: {pubkey: text}});
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
|
@ -499,26 +448,6 @@ function getMetadata(evt, relay) {
|
||||||
return {host, img, name, time, userName};
|
return {host, img, name, time, userName};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
function getReplyTo(evt) {
|
|
||||||
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)
|
|
||||||
return eventTags.length ? eventTags.at(-1)[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeForm = document.querySelector('#writeForm');
|
const writeForm = document.querySelector('#writeForm');
|
||||||
|
|
||||||
const elemShrink = () => {
|
const elemShrink = () => {
|
||||||
|
@ -973,25 +902,6 @@ function promptError(error, options = {}) {
|
||||||
errorOverlay.hidden = false;
|
errorOverlay.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
function validatePow(evt) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* run proof of work in a worker until at least the specified difficulty.
|
* run proof of work in a worker until at least the specified difficulty.
|
||||||
* if succcessful, the returned event contains the 'nonce' tag
|
* if succcessful, the returned event contains the 'nonce' tag
|
||||||
|
|
|
@ -1,19 +1,19 @@
|
||||||
/**
|
/**
|
||||||
* evaluate the difficulty of hex32 according to nip-13.
|
* evaluate the difficulty of hex32 according to nip-13.
|
||||||
* @param hex32 a string of 64 chars - 32 bytes in hex representation
|
* @param hex32 a string of 64 chars - 32 bytes in hex representation
|
||||||
*/
|
*/
|
||||||
export const zeroLeadingBitsCount = (hex32) => {
|
export const zeroLeadingBitsCount = (hex32: string) => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (let i = 0; i < 64; i += 2) {
|
for (let i = 0; i < 64; i += 2) {
|
||||||
const hexbyte = hex32.slice(i, i + 2); // grab next byte
|
const hexbyte = hex32.slice(i, i + 2); // grab next byte
|
||||||
if (hexbyte == '00') {
|
if (hexbyte === '00') {
|
||||||
count += 8;
|
count += 8;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// reached non-zero byte; count number of 0 bits in hexbyte
|
// reached non-zero byte; count number of 0 bits in hexbyte
|
||||||
const bits = parseInt(hexbyte, 16).toString(2).padStart(8, '0');
|
const bits = parseInt(hexbyte, 16).toString(2).padStart(8, '0');
|
||||||
for (let b = 0; b < 8; b++) {
|
for (let b = 0; b < 8; b++) {
|
||||||
if (bits[b] == '1' ) {
|
if (bits[b] === '1' ) {
|
||||||
break; // reached non-zero bit; stop
|
break; // reached non-zero bit; stop
|
||||||
}
|
}
|
||||||
count += 1;
|
count += 1;
|
|
@ -1,2 +1,4 @@
|
||||||
|
export {zeroLeadingBitsCount} from './crypto';
|
||||||
export {elem, parseTextContent} from './dom';
|
export {elem, parseTextContent} from './dom';
|
||||||
export {bounce, dateTime, formatTime} from './time';
|
export {bounce, dateTime, formatTime} from './time';
|
||||||
|
export {getHost, getNoxyUrl, isHttpUrl, isWssUrl} from './url';
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
export const getHost = (url: string) => {
|
||||||
|
try {
|
||||||
|
return new URL(url).host;
|
||||||
|
} catch(err) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isHttpUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
return ['http:', 'https:'].includes(new URL(url).protocol);
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isWssUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
return 'wss:' === new URL(url).protocol;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNoxyUrl = (
|
||||||
|
type: 'data' | 'meta',
|
||||||
|
url: string,
|
||||||
|
id: string,
|
||||||
|
relay: string,
|
||||||
|
) => {
|
||||||
|
if (!isHttpUrl(url)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const link = new URL(`https://noxy.nostr.ch/${type}`);
|
||||||
|
link.searchParams.set('id', id);
|
||||||
|
link.searchParams.set('relay', relay);
|
||||||
|
link.searchParams.set('url', url);
|
||||||
|
return link;
|
||||||
|
};
|
|
@ -1,5 +1,5 @@
|
||||||
import {getEventHash} from 'nostr-tools';
|
import {getEventHash} from 'nostr-tools';
|
||||||
import {zeroLeadingBitsCount} from './cryptoutils.js';
|
import {zeroLeadingBitsCount} from './utils/crypto';
|
||||||
|
|
||||||
function mine(event, difficulty, timeout = 5) {
|
function mine(event, difficulty, timeout = 5) {
|
||||||
const max = 256; // arbitrary
|
const max = 256; // arbitrary
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2021"
|
"moduleResolution": "node",
|
||||||
|
"target": "es2022"
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue