diff --git a/esbuildconf.js b/esbuildconf.js index 59e5fe4..1a4095a 100644 --- a/esbuildconf.js +++ b/esbuildconf.js @@ -17,8 +17,8 @@ export const options = { 'src/assets/star-fill.svg', 'src/favicon.ico', 'src/index.html', - 'src/main.css', - 'src/main.js', + 'src/styles/main.css', + 'src/main.ts', 'src/manifest.json', 'src/worker.js', ], diff --git a/package-lock.json b/package-lock.json index 50ee5c2..a26317c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,21 @@ { "name": "nostrweb", "version": "0.0.24", - "lockfileVersion": 2, + "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nostrweb", "version": "0.0.24", + "dependencies": { + "nostr-tools": "1.6.0" + }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.1.1", "esbuild": "^0.14.54", "esbuild-plugin-alias": "^0.2.1", "events": "^3.3.0", - "nostr-tools": "0.24.1" + "readable-stream": "4.3.0" } }, "node_modules/@esbuild-plugins/node-globals-polyfill": { @@ -40,17 +43,104 @@ "node": ">=12" } }, + "node_modules/@noble/curves": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.0.0.tgz", + "integrity": "sha512-2upgEu0iLiDVDZkNLeFV2+ht0BAVgQnEmCk6JsOch9Rp8xfkMCbvbAZlA2pBHQc73dbl+vFOXfqkf4uemdn0bw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "@noble/hashes": "1.3.0" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz", + "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, "node_modules/@noble/hashes": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-0.5.9.tgz", - "integrity": "sha512-7lN1Qh6d8DUGmfN36XRsbN/WcGIPNtTGhkw26vWId/DlCIGsYJJootTtPGghTLcn/AaXPx2Q0b3cacrwXa7OVw==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.0.0.tgz", + "integrity": "sha512-DZVbtY62kc3kkBtMHqwCOfXrT/hnoORy5BJ4+HU1IR59X0KWAOqsfzQPcUl/lQLlG7qXbe/fZ3r/emxtAl+sqg==" }, "node_modules/@noble/secp256k1": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.0.tgz", - "integrity": "sha512-kbacwGSsH/CTout0ZnZWxnW1B+jH/7r/WAAKLBtrRJ/+CUH7lgmQzl3GTrQua3SGKWNSDsS6lmjnDpIJ5Dxyaw==", - "dev": true, + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.1.tgz", + "integrity": "sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@scure/bip32": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.0.tgz", + "integrity": "sha512-bcKpo1oj54hGholplGLpqPHRbIsnbixFtc06nwuNM5/dwSXOq/AAYoIBRsBmnZJSdfeNW5rnff7NTAz3ZCqR9Q==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "@noble/curves": "~1.0.0", + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz", + "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@scure/bip39": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.0.tgz", + "integrity": "sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz", + "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==", "funding": [ { "type": "individual", @@ -58,6 +148,18 @@ } ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -78,43 +180,6 @@ } ] }, - "node_modules/browserify-aes": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", - "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", - "dev": true, - "dependencies": { - "buffer-xor": "^1.0.3", - "cipher-base": "^1.0.0", - "create-hash": "^1.1.0", - "evp_bytestokey": "^1.0.3", - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/browserify-cipher": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", - "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", - "dev": true, - "dependencies": { - "browserify-aes": "^1.0.4", - "browserify-des": "^1.0.0", - "evp_bytestokey": "^1.0.0" - } - }, - "node_modules/browserify-des": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", - "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", - "dev": true, - "dependencies": { - "cipher-base": "^1.0.1", - "des.js": "^1.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -139,122 +204,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/buffer-xor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", - "dev": true - }, - "node_modules/bufferutil": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz", - "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/cipher-base": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", - "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/create-hash": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", - "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", - "dev": true, - "dependencies": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "md5.js": "^1.3.4", - "ripemd160": "^2.0.1", - "sha.js": "^2.4.0" - } - }, - "node_modules/cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", - "dev": true, - "dependencies": { - "node-fetch": "2.6.7" - } - }, - "node_modules/d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "dev": true, - "dependencies": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/des.js": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", - "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/es5-ext": { - "version": "0.10.60", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.60.tgz", - "integrity": "sha512-jpKNXIt60htYG59/9FGf2PYT3pwMpnEbNKysU+k/4FGwyGtMotOvcZOuW+EmXXYASRqYSXQfGL5cVIthOTgbkg==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", - "dev": true, - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "dev": true, - "dependencies": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, "node_modules/esbuild": { "version": "0.14.54", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.54.tgz", @@ -617,6 +566,15 @@ "node": ">=12" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -626,45 +584,6 @@ "node": ">=0.8.x" } }, - "node_modules/evp_bytestokey": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", - "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", - "dev": true, - "dependencies": { - "md5.js": "^1.3.4", - "safe-buffer": "^5.1.1" - } - }, - "node_modules/ext": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", - "integrity": "sha512-sdBImtzkq2HpkdRLtlLWDa6w4DX22ijZLKx8BMPUuKe1c5lbN6xwQDQCxSfxBQnHZ13ls/FH0MQZx/q/gr6FQg==", - "dev": true, - "dependencies": { - "type": "^2.5.0" - } - }, - "node_modules/ext/node_modules/type": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/type/-/type-2.6.0.tgz", - "integrity": "sha512-eiDBDOmkih5pMbo9OqsqPRGMljLodLcwd5XD5JbtNB0o89xZAwynY9EdCDsJU7LtcVCClu9DvM7/0Ep1hYX3EQ==", - "dev": true - }, - "node_modules/hash-base": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", - "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.4", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -685,936 +604,56 @@ } ] }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true - }, - "node_modules/md5.js": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", - "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", - "dev": true, - "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "node_modules/micro-base": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/micro-base/-/micro-base-0.10.2.tgz", - "integrity": "sha512-lqqJrT7lfJtDmmiQ4zRLZuIJBk96t0RAc5pCrrWpL9zDeH5i/SUL85mku9HqzTI/OCZ8EQ3aicbMW+eK5Nyu5w==", - "deprecated": "Switch to @scure/base for audited version of the lib & updates", - "dev": true - }, - "node_modules/micro-bip32": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/micro-bip32/-/micro-bip32-0.1.0.tgz", - "integrity": "sha512-HxwYJzokbObqPHUqQuzRpCEqZ3EE4uHKrGlLX5ylt0ktD6m9LeS3RkWuQ1HApXEgrGMs3XgykN5Bic2YHE0f6Q==", - "deprecated": "Switch to @scure/bip32 for audited version of the lib & updates", - "dev": true, - "dependencies": { - "@noble/hashes": "^0.5.7", - "@noble/secp256k1": "^1.3.4", - "micro-base": "^0.10.1" - } - }, - "node_modules/micro-bip39": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/micro-bip39/-/micro-bip39-0.1.3.tgz", - "integrity": "sha512-lEaRG/MKxFQvG19lfJfPkLIG0rgT28nWud3otN+VgAbrozGqXn2PLaZuYPsy9guQjIZWBTvoLw/HDJQxmMXjMA==", - "deprecated": "Switch to @scure/bip39 for audited version of the lib & updates", - "dev": true, - "dependencies": { - "@noble/hashes": "^0.5.5", - "micro-base": "^0.10.1" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "dev": true - }, - "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dev": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp-build": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", - "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==", - "dev": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/nostr-tools": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-0.24.1.tgz", - "integrity": "sha512-+aUWblwNTYra8ZsjmfzxStr4XSKAb0gPsehNP3oBiSouLevqD3FWngc++kh8l+zfMYEPPGS6kS0i9iaq/5ZF6A==", - "dev": true, + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.6.0.tgz", + "integrity": "sha512-qjjJQ7YxJUMzgS24eVlxkZ87PKJtU6dlH04OzVuK6w+GSPL+VdUZkMe2lfSpnb7OkCrDIzmbFbtx+Q4LXdU2xw==", "dependencies": { - "@noble/hashes": "^0.5.7", - "@noble/secp256k1": "^1.5.2", - "browserify-cipher": ">=1", - "buffer": ">=5", - "create-hash": "^1.2.0", - "cross-fetch": "^3.1.4", - "micro-bip32": "^0.1.0", - "micro-bip39": "^0.1.3", - "websocket-polyfill": "^0.0.3" + "@noble/hashes": "1.0.0", + "@noble/secp256k1": "^1.7.1", + "@scure/base": "^1.1.1", + "@scure/bip32": "^1.1.5", + "@scure/bip39": "^1.1.1", + "prettier": "^2.8.4" } }, - "node_modules/readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "node_modules/prettier": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz", + "integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==", + "bin": { + "prettier": "bin-prettier.js" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", - "dev": true, - "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "node": ">=10.13.0" }, - "bin": { - "sha.js": "bin.js" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", - "dev": true - }, - "node_modules/tstl": { - "version": "2.5.12", - "resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.12.tgz", - "integrity": "sha512-xAJrE0R+PSxNXnQ7nJ1UPif/gBQYWMnEvIR6c7kKr+7oFrtalo+FunuJHLwpuH4DFClMB1hsaJTAOKkraET9Uw==", - "dev": true - }, - "node_modules/type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==", - "dev": true - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dev": true, - "dependencies": { - "is-typedarray": "^1.0.0" + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "dev": true, - "hasInstallScript": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, "engines": { - "node": ">=6.14.2" + "node": ">= 0.6.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", - "dev": true - }, - "node_modules/websocket": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", - "integrity": "sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==", + "node_modules/readable-stream": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.3.0.tgz", + "integrity": "sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ==", "dev": true, "dependencies": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.50", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10" }, "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/websocket-polyfill": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/websocket-polyfill/-/websocket-polyfill-0.0.3.tgz", - "integrity": "sha512-pF3kR8Uaoau78MpUmFfzbIRxXj9PeQrCuPepGE6JIsfsJ/o/iXr07Q2iQNzKSSblQJ0FiGWlS64N4pVSm+O3Dg==", - "dev": true, - "dependencies": { - "tstl": "^2.0.7", - "websocket": "^1.0.28" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "dev": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", - "dev": true, - "engines": { - "node": ">=0.10.32" - } - } - }, - "dependencies": { - "@esbuild-plugins/node-globals-polyfill": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.1.1.tgz", - "integrity": "sha512-MR0oAA+mlnJWrt1RQVQ+4VYuRJW/P2YmRTv1AsplObyvuBMnPHiizUF95HHYiSsMGLhyGtWufaq2XQg6+iurBg==", - "dev": true, - "requires": {} - }, - "@esbuild/linux-loong64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz", - "integrity": "sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==", - "dev": true, - "optional": true - }, - "@noble/hashes": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-0.5.9.tgz", - "integrity": "sha512-7lN1Qh6d8DUGmfN36XRsbN/WcGIPNtTGhkw26vWId/DlCIGsYJJootTtPGghTLcn/AaXPx2Q0b3cacrwXa7OVw==", - "dev": true - }, - "@noble/secp256k1": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.0.tgz", - "integrity": "sha512-kbacwGSsH/CTout0ZnZWxnW1B+jH/7r/WAAKLBtrRJ/+CUH7lgmQzl3GTrQua3SGKWNSDsS6lmjnDpIJ5Dxyaw==", - "dev": true - }, - "base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true - }, - "browserify-aes": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", - "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", - "dev": true, - "requires": { - "buffer-xor": "^1.0.3", - "cipher-base": "^1.0.0", - "create-hash": "^1.1.0", - "evp_bytestokey": "^1.0.3", - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "browserify-cipher": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", - "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", - "dev": true, - "requires": { - "browserify-aes": "^1.0.4", - "browserify-des": "^1.0.0", - "evp_bytestokey": "^1.0.0" - } - }, - "browserify-des": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", - "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", - "dev": true, - "requires": { - "cipher-base": "^1.0.1", - "des.js": "^1.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "buffer-xor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", - "dev": true - }, - "bufferutil": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz", - "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==", - "dev": true, - "requires": { - "node-gyp-build": "^4.3.0" - } - }, - "cipher-base": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", - "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "create-hash": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", - "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", - "dev": true, - "requires": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "md5.js": "^1.3.4", - "ripemd160": "^2.0.1", - "sha.js": "^2.4.0" - } - }, - "cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", - "dev": true, - "requires": { - "node-fetch": "2.6.7" - } - }, - "d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "dev": true, - "requires": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "des.js": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", - "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, - "es5-ext": { - "version": "0.10.60", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.60.tgz", - "integrity": "sha512-jpKNXIt60htYG59/9FGf2PYT3pwMpnEbNKysU+k/4FGwyGtMotOvcZOuW+EmXXYASRqYSXQfGL5cVIthOTgbkg==", - "dev": true, - "requires": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "next-tick": "^1.1.0" - } - }, - "es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", - "dev": true, - "requires": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "dev": true, - "requires": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, - "esbuild": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.54.tgz", - "integrity": "sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==", - "dev": true, - "requires": { - "@esbuild/linux-loong64": "0.14.54", - "esbuild-android-64": "0.14.54", - "esbuild-android-arm64": "0.14.54", - "esbuild-darwin-64": "0.14.54", - "esbuild-darwin-arm64": "0.14.54", - "esbuild-freebsd-64": "0.14.54", - "esbuild-freebsd-arm64": "0.14.54", - "esbuild-linux-32": "0.14.54", - "esbuild-linux-64": "0.14.54", - "esbuild-linux-arm": "0.14.54", - "esbuild-linux-arm64": "0.14.54", - "esbuild-linux-mips64le": "0.14.54", - "esbuild-linux-ppc64le": "0.14.54", - "esbuild-linux-riscv64": "0.14.54", - "esbuild-linux-s390x": "0.14.54", - "esbuild-netbsd-64": "0.14.54", - "esbuild-openbsd-64": "0.14.54", - "esbuild-sunos-64": "0.14.54", - "esbuild-windows-32": "0.14.54", - "esbuild-windows-64": "0.14.54", - "esbuild-windows-arm64": "0.14.54" - } - }, - "esbuild-android-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz", - "integrity": "sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==", - "dev": true, - "optional": true - }, - "esbuild-android-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz", - "integrity": "sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==", - "dev": true, - "optional": true - }, - "esbuild-darwin-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz", - "integrity": "sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==", - "dev": true, - "optional": true - }, - "esbuild-darwin-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz", - "integrity": "sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz", - "integrity": "sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz", - "integrity": "sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==", - "dev": true, - "optional": true - }, - "esbuild-linux-32": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz", - "integrity": "sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==", - "dev": true, - "optional": true - }, - "esbuild-linux-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz", - "integrity": "sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz", - "integrity": "sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz", - "integrity": "sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==", - "dev": true, - "optional": true - }, - "esbuild-linux-mips64le": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz", - "integrity": "sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==", - "dev": true, - "optional": true - }, - "esbuild-linux-ppc64le": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz", - "integrity": "sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==", - "dev": true, - "optional": true - }, - "esbuild-linux-riscv64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz", - "integrity": "sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==", - "dev": true, - "optional": true - }, - "esbuild-linux-s390x": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz", - "integrity": "sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==", - "dev": true, - "optional": true - }, - "esbuild-netbsd-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz", - "integrity": "sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==", - "dev": true, - "optional": true - }, - "esbuild-openbsd-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz", - "integrity": "sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==", - "dev": true, - "optional": true - }, - "esbuild-plugin-alias": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/esbuild-plugin-alias/-/esbuild-plugin-alias-0.2.1.tgz", - "integrity": "sha512-jyfL/pwPqaFXyKnj8lP8iLk6Z0m099uXR45aSN8Av1XD4vhvQutxxPzgA2bTcAwQpa1zCXDcWOlhFgyP3GKqhQ==", - "dev": true - }, - "esbuild-sunos-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz", - "integrity": "sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==", - "dev": true, - "optional": true - }, - "esbuild-windows-32": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz", - "integrity": "sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==", - "dev": true, - "optional": true - }, - "esbuild-windows-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz", - "integrity": "sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==", - "dev": true, - "optional": true - }, - "esbuild-windows-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz", - "integrity": "sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==", - "dev": true, - "optional": true - }, - "events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true - }, - "evp_bytestokey": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", - "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", - "dev": true, - "requires": { - "md5.js": "^1.3.4", - "safe-buffer": "^5.1.1" - } - }, - "ext": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", - "integrity": "sha512-sdBImtzkq2HpkdRLtlLWDa6w4DX22ijZLKx8BMPUuKe1c5lbN6xwQDQCxSfxBQnHZ13ls/FH0MQZx/q/gr6FQg==", - "dev": true, - "requires": { - "type": "^2.5.0" - }, - "dependencies": { - "type": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/type/-/type-2.6.0.tgz", - "integrity": "sha512-eiDBDOmkih5pMbo9OqsqPRGMljLodLcwd5XD5JbtNB0o89xZAwynY9EdCDsJU7LtcVCClu9DvM7/0Ep1hYX3EQ==", - "dev": true - } - } - }, - "hash-base": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", - "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", - "dev": true, - "requires": { - "inherits": "^2.0.4", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" - } - }, - "ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true - }, - "md5.js": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", - "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", - "dev": true, - "requires": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "micro-base": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/micro-base/-/micro-base-0.10.2.tgz", - "integrity": "sha512-lqqJrT7lfJtDmmiQ4zRLZuIJBk96t0RAc5pCrrWpL9zDeH5i/SUL85mku9HqzTI/OCZ8EQ3aicbMW+eK5Nyu5w==", - "dev": true - }, - "micro-bip32": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/micro-bip32/-/micro-bip32-0.1.0.tgz", - "integrity": "sha512-HxwYJzokbObqPHUqQuzRpCEqZ3EE4uHKrGlLX5ylt0ktD6m9LeS3RkWuQ1HApXEgrGMs3XgykN5Bic2YHE0f6Q==", - "dev": true, - "requires": { - "@noble/hashes": "^0.5.7", - "@noble/secp256k1": "^1.3.4", - "micro-base": "^0.10.1" - } - }, - "micro-bip39": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/micro-bip39/-/micro-bip39-0.1.3.tgz", - "integrity": "sha512-lEaRG/MKxFQvG19lfJfPkLIG0rgT28nWud3otN+VgAbrozGqXn2PLaZuYPsy9guQjIZWBTvoLw/HDJQxmMXjMA==", - "dev": true, - "requires": { - "@noble/hashes": "^0.5.5", - "micro-base": "^0.10.1" - } - }, - "minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - }, - "next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "dev": true - }, - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dev": true, - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "node-gyp-build": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", - "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==", - "dev": true - }, - "nostr-tools": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-0.24.1.tgz", - "integrity": "sha512-+aUWblwNTYra8ZsjmfzxStr4XSKAb0gPsehNP3oBiSouLevqD3FWngc++kh8l+zfMYEPPGS6kS0i9iaq/5ZF6A==", - "dev": true, - "requires": { - "@noble/hashes": "^0.5.7", - "@noble/secp256k1": "^1.5.2", - "browserify-cipher": ">=1", - "buffer": ">=5", - "create-hash": "^1.2.0", - "cross-fetch": "^3.1.4", - "micro-bip32": "^0.1.0", - "micro-bip39": "^0.1.3", - "websocket-polyfill": "^0.0.3" - } - }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", - "dev": true, - "requires": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", - "dev": true - }, - "tstl": { - "version": "2.5.12", - "resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.12.tgz", - "integrity": "sha512-xAJrE0R+PSxNXnQ7nJ1UPif/gBQYWMnEvIR6c7kKr+7oFrtalo+FunuJHLwpuH4DFClMB1hsaJTAOKkraET9Uw==", - "dev": true - }, - "type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==", - "dev": true - }, - "typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dev": true, - "requires": { - "is-typedarray": "^1.0.0" - } - }, - "utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "dev": true, - "requires": { - "node-gyp-build": "^4.3.0" - } - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true - }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", - "dev": true - }, - "websocket": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", - "integrity": "sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==", - "dev": true, - "requires": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.50", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - } - }, - "websocket-polyfill": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/websocket-polyfill/-/websocket-polyfill-0.0.3.tgz", - "integrity": "sha512-pF3kR8Uaoau78MpUmFfzbIRxXj9PeQrCuPepGE6JIsfsJ/o/iXr07Q2iQNzKSSblQJ0FiGWlS64N4pVSm+O3Dg==", - "dev": true, - "requires": { - "tstl": "^2.0.7", - "websocket": "^1.0.28" - } - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "dev": true, - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", - "dev": true } } } diff --git a/package.json b/package.json index bbe1fd3..ba92ecb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "esbuild": "^0.14.54", "esbuild-plugin-alias": "^0.2.1", "events": "^3.3.0", - "nostr-tools": "0.24.1" + "readable-stream": "4.3.0" + }, + "dependencies": { + "nostr-tools": "1.6.0" }, "scripts": { "build": "node tools/build.js", diff --git a/src/about.html b/src/about.html index 869f122..a7c10fc 100644 --- a/src/about.html +++ b/src/about.html @@ -4,23 +4,25 @@ about / nostr - + -
-

nostr: notes and other stuff transmitted by relays

- this is a nostr web client.
- source code is at git.qcode.ch/nostr/nostrweb. -

- you are looking at version #[PKG_VERSION]#, built at git commit - #[GIT_COMMIT]#. -

-

- for more information about nostr protocol, check out - github.com/nostr-protocol/nostr#readme. -

- back to nostr.ch +
+
+

nostr: notes and other stuff transmitted by relays

+ this is a nostr web client.
+ source code is at git.qcode.ch/nostr/nostrweb. +

+ you are looking at version #[PKG_VERSION]#, built at git commit + #[GIT_COMMIT]#. +

+

+ for more information about nostr protocol, check out + github.com/nostr-protocol/nostr#readme. +

+ back to nostr.ch +
diff --git a/src/domutil.js b/src/domutil.js deleted file mode 100644 index bbdb1ee..0000000 --- a/src/domutil.js +++ /dev/null @@ -1,92 +0,0 @@ -/** - * example usage: - * - * const props = {className: 'btn', onclick: async (e) => alert('hi')}; - * const btn = elem('button', props, ['download']); - * document.body.append(btn); - * - * @param {string} name - * @param {HTMLElement.prototype} props - * @param {Array} children - * @return HTMLElement - */ -export function elem(name = 'div', {data, ...props} = {}, children = []) { - const el = document.createElement(name); - Object.assign(el, props); - if (['number', 'string'].includes(typeof children)) { - el.append(children); - } else { - el.append(...children); - } - if (data) { - Object.entries(data).forEach(([key, value]) => el.dataset[key] = value); - } - return el; -} - -function isValidURL(url) { - if (!['http:', 'https:'].includes(url.protocol)) { - return false; - } - if (!['', '443', '80'].includes(url.port)) { - return false; - } - if (url.hostname === 'localhost') { - return false; - } - const lastDot = url.hostname.lastIndexOf('.'); - if (lastDot < 1) { - return false; - } - if (url.hostname.slice(lastDot) === '.local') { - return false; - } - if (url.hostname.slice(lastDot + 1).match(/^[\d]+$/)) { // there should be no tld with numbers, possible ipv4 - return false; - } - if (url.hostname.includes(':')) { // possibly an ipv6 addr; certainly an invalid hostname - return false; - } - return true; -} - -export function parseTextContent(string) { - let firstLink; - return [string - .trimRight() - .replaceAll(/\n{3,}/g, '\n\n') - .split('\n') - .map(line => { - const words = line.split(/\s/); - return words.map(word => { - if (word.match(/^ln(tbs?|bcr?t?)[a-z0-9]+$/g)) { - return elem('a', { - href: `lightning:${word}` - }, `lightning:${word.slice(0, 24)}…`); - } - if (!word.match(/^(https?:\/\/|www\.)\S*/)) { - return word; - } - try { - if (!word.startsWith('http')) { - word = 'https://' + word; - } - const url = new URL(word); - if (!isValidURL(url)) { - return word; - } - firstLink = firstLink || url.href; - return elem('a', { - href: url.href, - target: '_blank', - rel: 'noopener noreferrer' - }, url.href.slice(url.protocol.length + 2)); - } catch (err) { - return word; - } - }) - .reduce((acc, word) => [...acc, word, ' '], []); - }) - .reduce((acc, words) => [...acc, ...words, elem('br')], []), - {firstLink}]; -} diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..8ed8064 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,61 @@ +import {Event} from 'nostr-tools'; +import {zeroLeadingBitsCount} from './utils/crypto'; + +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; +}; diff --git a/src/index.html b/src/index.html index 333f00c..42e5f25 100644 --- a/src/index.html +++ b/src/index.html @@ -2,141 +2,110 @@ - + + + nostr - + -
- - - - - - -
-
- - - +
+
+
- - + + +
- - - - - - - -
- -
- - - - - - -
- - -
-
-
- - - - -
-
- - - - -
- - - -
-
- -
-
- -
+ + + + +
+ + diff --git a/src/main.js b/src/main.js deleted file mode 100644 index e1e6634..0000000 --- a/src/main.js +++ /dev/null @@ -1,1181 +0,0 @@ -import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; -import {bounce} from './utils.js'; -import {zeroLeadingBitsCount} from './cryptoutils.js'; -import {elem, parseTextContent} from './domutil.js'; -import {dateTime, formatTime} from './timeutil.js'; -// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ - -const pool = relayPool(); -// pool.addRelay('wss://relay.nostr.info', {read: true, write: true}); -// pool.addRelay('wss://relay.damus.io', {read: true, write: true}); -// pool.addRelay('wss://relay.snort.social', {read: true, write: true}); - -pool.addRelay('wss://relay.nostr.ch', {read: true, write: true}); -pool.addRelay('wss://nostr.openchain.fr', {read: true, write: true}); -pool.addRelay('wss://eden.nostr.land', {read: true, write: true}); -pool.addRelay('wss://nostr.einundzwanzig.space', {read: true, write: true}); -pool.addRelay('wss://relay.nostrich.de', {read: true, write: true}); -pool.addRelay('wss://nostr.cercatrova.me', {read: true, write: true}); - -function onEvent(evt, relay) { - switch (evt.kind) { - case 0: - handleMetadata(evt, relay); - break; - case 1: - handleTextNote(evt, relay); - break; - case 2: - handleRecommendServer(evt, relay); - break; - case 3: - // handleContactList(evt, relay); - break; - case 7: - handleReaction(evt, relay); - default: - // console.log(`TODO: add support for event kind ${evt.kind}`/*, evt*/) - } -} - -let pubkey = localStorage.getItem('pub_key') || (() => { - const privatekey = generatePrivateKey(); - const pubkey = getPublicKey(privatekey); - localStorage.setItem('private_key', privatekey); - localStorage.setItem('pub_key', pubkey); - return pubkey; -})(); - -const subList = []; -const unSubAll = () => { - subList.forEach(sub => sub.unsub()); - subList.length = 0; -}; - -window.addEventListener('popstate', (event) => { - // console.log(`popstate path: ${location.pathname}, state: ${JSON.stringify(event.state)}`); - unSubAll(); - if (event.state?.author) { - subProfile(event.state.author); - return; - } - if (event.state?.pubOrEvt) { - subNoteAndProfile(event.state.pubOrEvt); - return; - } - if (event.state?.eventId) { - subTextNote(event.state.eventId); - return; - } - sub24hFeed(); - showFeed(); -}); - -switch(location.pathname) { - case '/': - history.pushState({}, '', '/'); - sub24hFeed(); - break; - default: - const pubOrEvt = location.pathname.slice(1); - if (pubOrEvt.length === 64 && pubOrEvt.match(/^[0-9a-f]+$/)) { - history.pushState({pubOrEvt}, '', `/${pubOrEvt}`); - subNoteAndProfile(pubOrEvt); - } - break; -} - -function sub24hFeed() { - subList.push(pool.sub({ - cb: onEvent, - filter: { - kinds: [0, 1, 2, 7], - // until: Math.floor(Date.now() * 0.001), - since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)), - limit: 450, - } - })); -} - -function subNoteAndProfile(id) { - subProfile(id); - subTextNote(id); -} - -function subTextNote(eventId) { - subList.push(pool.sub({ - cb: (evt, relay) => { - clearTextNoteDetail(); - showTextNoteDetail(evt, relay); - }, - filter: { - ids: [eventId], - kinds: [1], - limit: 1, - } - })); -} - -function subProfile(pubkey) { - subList.push(pool.sub({ - cb: (evt, relay) => { - renderProfile(evt, relay); - showProfileDetail(); - }, - filter: { - authors: [pubkey], - kinds: [0], - limit: 1, - } - })); - // get notes for profile - subList.push(pool.sub({ - cb: (evt, relay) => { - showTextNoteDetail(evt, relay); - showProfileDetail(); - }, - filter: { - authors: [pubkey], - kinds: [1], - limit: 450, - } - })); -} - -const detailContainer = document.querySelector('#detail'); -const profileContainer = document.querySelector('#profile'); -const profileAbout = profileContainer.querySelector('.profile-about'); -const profileName = profileContainer.querySelector('.profile-name'); -const profilePubkey = profileContainer.querySelector('.profile-pubkey'); -const profilePubkeyLabel = profileContainer.querySelector('.profile-pubkey-label'); -const profileImage = profileContainer.querySelector('.profile-image'); -const textNoteContainer = document.querySelector('#textnote'); - -function clearProfile() { - profileAbout.textContent = ''; - profileName.textContent = ''; - profilePubkey.textContent = ''; - profilePubkeyLabel.hidden = true; - profileImage.removeAttribute('src'); - profileImage.hidden = true; -} -function renderProfile(evt, relay) { - profileContainer.dataset.pubkey = evt.pubkey; - profilePubkey.textContent = evt.pubkey; - profilePubkeyLabel.hidden = false; - const content = parseContent(evt.content); - if (content) { - profileAbout.textContent = content.about; - profileName.textContent = content.name; - const noxyImg = validatePow(evt) && getNoxyUrl('data', content.picture, evt.id, relay); - if (noxyImg) { - profileImage.setAttribute('src', noxyImg); - profileImage.hidden = false; - } - } -} - -function showProfileDetail() { - profileContainer.hidden = false; - textNoteContainer.hidden = false; - showDetail(); -} - -function clearTextNoteDetail() { - textNoteContainer.replaceChildren([]); -} - -function showTextNoteDetail(evt, relay) { - if (!textNoteContainer.querySelector(`[data-id="${evt.id}"]`)) { - textNoteContainer.append(createTextNote(evt, relay)); - } - textNoteContainer.hidden = false; - profileContainer.hidden = true; - showDetail(); -} - -function showDetail() { - feedContainer.hidden = true; - detailContainer.hidden = false; -} - -function showFeed() { - feedContainer.hidden = false; - detailContainer.hidden = true; -} - -document.querySelector('label[for="feed"]').addEventListener('click', () => { - if (location.pathname !== '/') { - showFeed(); - history.pushState({}, '', '/'); - unSubAll(); - sub24hFeed(); - } -}); - -document.body.addEventListener('click', (e) => { - const button = e.target.closest('button'); - const pubkey = e.target.closest('[data-pubkey]')?.dataset.pubkey; - const id = e.target.closest('[data-id]')?.dataset.id; - const relay = e.target.closest('[data-relay]')?.dataset.relay; - if (button && button.name === 'reply') { - if (localStorage.getItem('reply_to') === id) { - writeInput.blur(); - return; - } - appendReplyForm(button.closest('.buttons')); - localStorage.setItem('reply_to', id); - return; - } - if (button && button.name === 'star') { - upvote(id, pubkey); - return; - } - if (button && button.name === 'back') { - hideNewMessage(true); - return; - } - const username = e.target.closest('.mbox-username') - if (username) { - history.pushState({author: pubkey}, '', `/${pubkey}`); - unSubAll(); - clearProfile(); - clearTextNoteDetail(); - subProfile(pubkey); - showProfileDetail(); - return; - } - const eventTime = e.target.closest('.mbox-header time'); - if (eventTime) { - history.pushState({eventId: id, relay}, '', `/${id}`); - unSubAll(); - clearTextNoteDetail(); - subTextNote(id); - return; - } - // const container = e.target.closest('[data-append]'); - // if (container) { - // container.append(...parseTextContent(container.dataset.append)); - // delete container.dataset.append; - // return; - // } -}); - -const textNoteList = []; // could use indexDB -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 renderFeed = bounce(() => { - const now = Math.floor(Date.now() * 0.001); - const sortedFeeds = textNoteList - // dont render notes from the future - .filter(note => note.created_at <= now) - // if difficulty filter is configured dont render notes with too little pow - .filter(note => { - return !fitlerDifficulty || note.tags.some(([tag, , commitment]) => { - return tag === 'nonce' && commitment >= fitlerDifficulty && zeroLeadingBitsCount(note.id) >= fitlerDifficulty; - }); - }) - .sort(sortByCreatedAt).reverse(); - sortedFeeds.forEach((evt, i) => { - if (feedDomMap[evt.id]) { - // TODO check eventRelayMap if event was published to different relays - return; - } - const article = createTextNote(evt, eventRelayMap[evt.id]); - if (i === 0) { - feedContainer.append(article); - } else { - feedDomMap[sortedFeeds[i - 1].id].before(article); - } - feedDomMap[evt.id] = article; - }); -}, 17); // (16.666 rounded, a bit arbitrary but that it doesnt update more than 60x per s) - -function handleTextNote(evt, relay) { - if (eventRelayMap[evt.id]) { - eventRelayMap[evt.id] = [relay, ...(eventRelayMap[evt.id])]; - } else { - eventRelayMap[evt.id] = [relay]; - if (evt.tags.some(hasEventTag)) { - handleReply(evt, relay); - } else { - textNoteList.push(evt); - } - renderFeed(); - } -} - -const replyList = []; -const replyDomMap = {}; -const replyToMap = {}; - -function handleReply(evt, relay) { - if ( - replyDomMap[evt.id] // already rendered probably received from another relay - || evt.tags.some(isMention) // ignore mentions for now - ) { - return; - } - if (!replyToMap[evt.id]) { - replyToMap[evt.id] = getReplyTo(evt); - } - replyList.push({ - replyTo: replyToMap[evt.id], - ...evt, - }); - renderReply(evt, relay); -} - -const reactionMap = {}; - -const getReactionList = (id) => { - return reactionMap[id]?.map(({content}) => content) || []; -}; - -function handleReaction(evt, relay) { - // last id is the note that is being reacted to https://github.com/nostr-protocol/nips/blob/master/25.md - const lastEventTag = evt.tags.filter(hasEventTag).at(-1); - if (!lastEventTag || !evt.content.length) { - // ignore reactions with no content - return; - } - const [, eventId] = lastEventTag; - if (reactionMap[eventId]) { - if (reactionMap[eventId].find(reaction => reaction.id === evt.id)) { - // already received this reaction from a different relay - return; - } - reactionMap[eventId] = [evt, ...(reactionMap[eventId])]; - } else { - reactionMap[eventId] = [evt]; - } - const article = feedDomMap[eventId] || replyDomMap[eventId]; - if (article) { - const button = article.querySelector('button[name="star"]'); - const reactions = button.querySelector('[data-reactions]'); - reactions.textContent = reactionMap[eventId].length; - if (evt.pubkey === pubkey) { - const star = button.querySelector('img[src*="star"]'); - star?.setAttribute('src', 'assets/star-fill.svg'); - star?.setAttribute('title', getReactionList(eventId).join(' ')); - } - } -} - -// feed -const feedContainer = document.querySelector('#homefeed'); -const feedDomMap = {}; -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() { - Object.keys(feedDomMap).forEach(key => delete feedDomMap[key]); - Object.keys(replyDomMap).forEach(key => delete replyDomMap[key]); - feedContainer.replaceChildren([]); - renderFeed(); -} - -setInterval(() => { - document.querySelectorAll('time[datetime]').forEach(timeElem => { - timeElem.textContent = formatTime(new Date(timeElem.dateTime)); - }); -}, 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 = []; -let fetchPending; -const fetchNext = (href, id, relay) => { - const noxy = getNoxyUrl('meta', href, id, relay); - const previewId = noxy.searchParams.toString(); - if (fetchPending) { - fetchQue.push({href, id, relay}); - return previewId; - } - fetchPending = fetch(noxy.href) - .then(data => { - if (data.status === 200) { - return data.json(); - } - // fetchQue.push({href, id, relay}); // could try one more time - return Promise.reject(data); - }) - .then(meta => { - const container = document.getElementById(previewId); - const content = []; - if (meta.images[0]) { - content.push(elem('img', {className: 'preview-image', loading: 'lazy', src: getNoxyUrl('data', meta.images[0], id, relay).href})); - } - if (meta.title) { - content.push(elem('h2', {className: 'preview-title'}, meta.title)); - } - if (meta.descr) { - content.push(elem('p', {className: 'preview-descr'}, meta.descr)) - } - if (content.length) { - container.append(elem('a', {href, rel: 'noopener noreferrer', target: '_blank'}, content)); - container.classList.add('preview-loaded'); - } - }) - .finally(() => { - fetchPending = false; - if (fetchQue.length) { - const {href, id, relay} = fetchQue.shift(); - return fetchNext(href, id, relay); - } - }) - .catch(err => err.text && err.text()) - .then(errMsg => errMsg && console.warn(errMsg)); - return previewId; -}; - -function linkPreview(href, id, relay) { - if ((/\.(gif|jpe?g|png)$/i).test(href)) { - return elem('div', {}, - [elem('img', {className: 'preview-image-only', loading: 'lazy', src: getNoxyUrl('data', href, id, relay).href})] - ); - } - const previewId = fetchNext(href, id, relay); - return elem('div', { - className: 'preview', - id: previewId - }); -} - -function createTextNote(evt, relay) { - const {host, img, name, time, userName} = getMetadata(evt, relay); - const replies = replyList.filter(({replyTo}) => replyTo === evt.id); - // const isLongContent = evt.content.trimRight().length > 280; - // const content = isLongContent ? evt.content.slice(0, 280) : evt.content; - const hasReactions = reactionMap[evt.id]?.length > 0; - const didReact = hasReactions && !!reactionMap[evt.id].find(reaction => reaction.pubkey === pubkey); - const replyFeed = replies[0] ? replies.sort(sortByCreatedAt).map(e => replyDomMap[e.id] = createTextNote(e, relay)) : []; - const [content, {firstLink}] = parseTextContent(evt.content); - const body = elem('div', {className: 'mbox-body'}, [ - elem('header', { - className: 'mbox-header', - title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${host}\n\nEvent-id: ${evt.id} - ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} - ${evt.content}` - }, [ - elem('small', {}, [ - elem('strong', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`}, name || userName), - ' ', - elem('time', {dateTime: time.toISOString()}, formatTime(time)), - ]), - ]), - elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ - ...content, - (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : '', - ]), - elem('div', {className: 'buttons'}, [ - elem('button', {name: 'reply', type: 'button'}, [ - elem('img', {height: 24, width: 24, src: 'assets/comment.svg'}) - ]), - elem('button', {name: 'star', type: 'button'}, [ - elem('img', { - alt: didReact ? '✭' : '✩', // ♥ - height: 24, width: 24, - src: `assets/${didReact ? 'star-fill' : 'star'}.svg`, - title: getReactionList(evt.id).join(' '), - }), - elem('small', {data: {reactions: ''}}, hasReactions ? reactionMap[evt.id].length : ''), - ]), - ]), - ]); - if (restoredReplyTo === evt.id) { - appendReplyForm(body.querySelector('.buttons')); - requestAnimationFrame(() => updateElemHeight(writeInput)); - } - return renderArticle([ - elem('div', {className: 'mbox-img'}, [img]), body, - replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '', - ], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); -} - -function renderReply(evt, relay) { - const replyToId = replyToMap[evt.id]; - const article = feedDomMap[replyToId] || replyDomMap[replyToId]; - if (!article) { // root article has not been rendered - return; - } - let replyContainer = article.querySelector('.mobx-replies'); - if (!replyContainer) { - replyContainer = elem('div', {className: 'mobx-replies'}); - article.append(replyContainer); - } - const reply = createTextNote(evt, relay); - replyContainer.append(reply); - replyDomMap[evt.id] = reply; -} - -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) { - if (feedDomMap[evt.id] || !isWssUrl(evt.content)) { - return; - } - const art = renderRecommendServer(evt, relay); - if (textNoteList.length < 2) { - feedContainer.append(art); - } else { - const closestTextNotes = textNoteList - .filter(note => !fitlerDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && commitment >= fitlerDifficulty)) - .sort(sortEventCreatedAt(evt.created_at)); - feedDomMap[closestTextNotes[0].id]?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed - } - feedDomMap[evt.id] = art; -} - -function handleContactList(evt, relay) { - if (feedDomMap[evt.id]) { - return; - } - const art = renderUpdateContact(evt, relay); - if (textNoteList.length < 2) { - feedContainer.append(art); - return; - } - const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); - feedDomMap[closestTextNotes[0].id].after(art); - feedDomMap[evt.id] = art; - // const user = userList.find(u => u.pupkey === evt.pubkey); - // if (user) { - // console.log(`TODO: add contact list for ${evt.pubkey.slice(0, 8)} on ${relay}`, evt.tags); - // } else { - // tempContactList[relay] = tempContactList[relay] - // ? [...tempContactList[relay], evt] - // : [evt]; - // } -} - -function renderUpdateContact(evt, relay) { - const {img, time, userName} = getMetadata(evt, relay); - const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ - elem('header', {className: 'mbox-header'}, [ - elem('small', {}, [ - - ]), - ]), - elem('pre', {title: JSON.stringify(evt.content)}, [ - elem('strong', {}, userName), - ' updated contacts: ', - JSON.stringify(evt.tags), - ]), - ]); - return renderArticle([img, body], {className: 'mbox-updated-contact', data: {id: evt.id, pubkey: evt.pubkey, relay}}); -} - -function renderRecommendServer(evt, relay) { - const {img, name, time, userName} = getMetadata(evt, relay); - const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ - elem('header', {className: 'mbox-header'}, [ - elem('small', {}, [ - elem('strong', {}, userName) - ]), - ]), - ` recommends server: ${evt.content}`, - ]); - return renderArticle([ - elem('div', {className: 'mbox-img'}, [img]), body - ], {className: 'mbox-recommend-server', data: {id: evt.id, pubkey: evt.pubkey}}); -} - -function renderArticle(content, props = {}) { - const className = props.className ? ['mbox', props?.className].join(' ') : 'mbox'; - return elem('article', {...props, className}, content); -} - -const userList = []; -// const tempContactList = {}; - -function parseContent(content) { - try { - return JSON.parse(content); - } catch(err) { - console.log(evt); - console.error(err); - } -} - -function handleMetadata(evt, relay) { - const content = parseContent(evt.content); - if (content) { - setMetadata(evt, relay, content); - } -} - -function setMetadata(evt, relay, content) { - let user = userList.find(u => u.pubkey === evt.pubkey); - const picture = getNoxyUrl('data', content.picture, evt.id, relay).href; - if (!user) { - user = { - metadata: {[relay]: content}, - ...(content.picture && {picture}), - pubkey: evt.pubkey, - }; - userList.push(user); - } else { - user.metadata[relay] = { - ...user.metadata[relay], - timestamp: evt.created_at, - ...content, - }; - // use only the first profile pic (for now), different pics on each releay are not supported yet - if (!user.picture) { - user.picture = picture; - } - } - // update profile images - if (user.picture && validatePow(evt)) { - document.body - .querySelectorAll(`canvas[data-pubkey="${evt.pubkey}"]`) - .forEach(canvas => (canvas.parentNode.replaceChild(elem('img', {src: user.picture}), canvas))); - } - if (user.metadata[relay].name) { - document.body - .querySelectorAll(`[data-id="${evt.pubkey}"] .mbox-username:not(.mbox-kind0-name)`) - .forEach(username => { - username.textContent = user.metadata[relay].name; - username.classList.add('mbox-kind0-name'); - }); - } - // if (tempContactList[relay]) { - // const updates = tempContactList[relay].filter(update => update.pubkey === evt.pubkey); - // if (updates) { - // console.log('TODO: add contact list (kind 3)', updates); - // } - // } -} - -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 canvas = elem('canvas', {height: 80, width: 80, data: {pubkey: text}}); - const context = canvas.getContext('2d'); - const color = `#${text.slice(0, 6)}`; - context.fillStyle = color; - context.fillRect(0, 0, 80, 80); - context.fillStyle = '#111'; - context.fillRect(0, 50, 80, 32); - context.font = 'bold 18px monospace'; - if (color === '#000000') { - context.fillStyle = '#fff'; - } - context.fillText(text.slice(0, 8), 2, 46); - return canvas; -} - -function getMetadata(evt, relay) { - const host = getHost(relay); - const user = userList.find(user => user.pubkey === evt.pubkey); - const userImg = user?.picture; - const name = user?.metadata[relay]?.name; - const userName = name || evt.pubkey.slice(0, 8); - const userAbout = user?.metadata[relay]?.about || ''; - const img = (userImg && validatePow(evt)) ? elem('img', { - alt: `${userName} ${host}`, - loading: 'lazy', - src: userImg, - title: `${userName} on ${host} ${userAbout}`, - }) : elemCanvas(evt.pubkey); - const isReply = !!replyToMap[evt.id]; - const time = new Date(evt.created_at * 1000); - return {host, img, isReply, 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 writeInput = document.querySelector('textarea[name="message"]'); - -const elemShrink = () => { - const height = writeInput.style.height || writeInput.getBoundingClientRect().height; - const shrink = elem('div', {className: 'shrink-out'}); - shrink.style.height = `${height}px`; - shrink.addEventListener('animationend', () => shrink.remove(), {once: true}); - return shrink; -} - -writeInput.addEventListener('focusout', () => { - const reply_to = localStorage.getItem('reply_to'); - if (reply_to && writeInput.value === '') { - writeInput.addEventListener('transitionend', (event) => { - if (!reply_to || reply_to === localStorage.getItem('reply_to') && !writeInput.style.height) { // should prob use some class or data-attr instead of relying on height - writeForm.after(elemShrink()); - writeForm.remove(); - localStorage.removeItem('reply_to'); - } - }, {once: true}); - } -}); - -function appendReplyForm(el) { - writeForm.before(elemShrink()); - writeInput.blur(); - writeInput.style.removeProperty('height'); - el.after(writeForm); - if (writeInput.value && !writeInput.value.trimRight()) { - writeInput.value = ''; - } else { - requestAnimationFrame(() => updateElemHeight(writeInput)); - } - requestAnimationFrame(() => writeInput.focus()); -} - -const lockScroll = () => document.body.style.overflow = 'hidden'; -const unlockScroll = () => document.body.style.removeProperty('overflow'); - -const newMessageDiv = document.querySelector('#newMessage'); -document.querySelector('#bubble').addEventListener('click', (e) => { - localStorage.removeItem('reply_to'); // should it forget old replyto context? - newMessageDiv.prepend(writeForm); - hideNewMessage(false); - writeInput.focus(); - if (writeInput.value.trimRight()) { - writeInput.style.removeProperty('height'); - } - lockScroll(); - requestAnimationFrame(() => updateElemHeight(writeInput)); -}); - -document.body.addEventListener('keyup', (e) => { - if (e.key === 'Escape') { - hideNewMessage(true); - } -}); - -function hideNewMessage(hide) { - unlockScroll(); - newMessageDiv.hidden = hide; -} - -let fitlerDifficulty = JSON.parse(localStorage.getItem('filter_difficulty')) ?? 0; -const filterDifficultyInput = document.querySelector('#filterDifficulty'); -const filterDifficultyDisplay = document.querySelector('[data-display="filter_difficulty"]'); -filterDifficultyInput.addEventListener('input', (e) => { - localStorage.setItem('filter_difficulty', filterDifficultyInput.valueAsNumber); - fitlerDifficulty = filterDifficultyInput.valueAsNumber; - filterDifficultyDisplay.textContent = filterDifficultyInput.value; - rerenderFeed(); -}); -filterDifficultyInput.value = fitlerDifficulty; -filterDifficultyDisplay.textContent = filterDifficultyInput.value; - -// arbitrary difficulty default, still experimenting. -let difficulty = JSON.parse(localStorage.getItem('mining_target')) ?? 16; -const miningTargetInput = document.querySelector('#miningTarget'); -miningTargetInput.addEventListener('input', (e) => { - localStorage.setItem('mining_target', miningTargetInput.valueAsNumber); - difficulty = miningTargetInput.valueAsNumber; -}); -miningTargetInput.value = difficulty; - -let timeout = JSON.parse(localStorage.getItem('mining_timeout')) ?? 5; -const miningTimeoutInput = document.querySelector('#miningTimeout'); -miningTimeoutInput.addEventListener('input', (e) => { - localStorage.setItem('mining_timeout', miningTimeoutInput.valueAsNumber); - timeout = miningTimeoutInput.valueAsNumber; -}); -miningTimeoutInput.value = timeout; - -async function upvote(eventId, eventPubkey) { - const note = replyList.find(r => r.id === eventId) || textNoteList.find(n => n.id === (eventId)); - const tags = [ - ...note.tags - .filter(tag => ['e', 'p'].includes(tag[0])) // take e and p tags from event - .map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields - ['e', eventId], ['p', eventPubkey], // last e and p tag is the id and pubkey of the note being reacted to (nip-25) - ]; - const article = (feedDomMap[eventId] || replyDomMap[eventId]); - const reactionBtn = article.querySelector('[name="star"]'); - const statusElem = article.querySelector('[data-reactions]'); - reactionBtn.disabled = true; - const newReaction = await powEvent({ - kind: 7, - pubkey, // TODO: lib could check that this is the pubkey of the key to sign with - content: '+', - tags, - created_at: Math.floor(Date.now() * 0.001), - }, {difficulty, statusElem, timeout}).catch(console.warn); - if (!newReaction) { - statusElem.textContent = reactionMap[eventId]?.length; - reactionBtn.disabled = false; - return; - } - const privatekey = localStorage.getItem('private_key'); - const sig = await signEvent(newReaction, privatekey).catch(console.error); - if (sig) { - statusElem.textContent = 'publishing…'; - const ev = await pool.publish({...newReaction, sig}, (status, url) => { - if (status === 0) { - console.info(`publish request sent to ${url}`); - } - if (status === 1) { - console.info(`event published by ${url}`); - } - }).catch(console.error); - reactionBtn.disabled = false; - } -} - -// send -const sendStatus = document.querySelector('#sendstatus'); -const onSendError = err => sendStatus.textContent = err.message; -const publish = document.querySelector('#publish'); -writeForm.addEventListener('submit', async (e) => { - e.preventDefault(); - // const pubkey = localStorage.getItem('pub_key'); - const privatekey = localStorage.getItem('private_key'); - if (!pubkey || !privatekey) { - return onSendError(new Error('no pubkey/privatekey')); - } - const content = writeInput.value.trimRight(); - if (!content) { - return onSendError(new Error('message is empty')); - } - const replyTo = localStorage.getItem('reply_to'); - const close = () => { - sendStatus.textContent = ''; - writeInput.value = ''; - writeInput.style.removeProperty('height'); - publish.disabled = true; - if (replyTo) { - localStorage.removeItem('reply_to'); - newMessageDiv.append(writeForm); - } - hideNewMessage(true); - }; - const tags = replyTo ? [['e', replyTo, eventRelayMap[replyTo][0]]] : []; - const newEvent = await powEvent({ - kind: 1, - content, - pubkey, - tags, - created_at: Math.floor(Date.now() * 0.001), - }, {difficulty, statusElem: sendStatus, timeout}).catch(console.warn); - if (!newEvent) { - close(); - return; - } - const sig = await signEvent(newEvent, privatekey).catch(onSendError); - if (sig) { - sendStatus.textContent = 'publishing…'; - const ev = await pool.publish({...newEvent, sig}, (status, url) => { - if (status === 0) { - console.info(`publish request sent to ${url}`); - } - if (status === 1) { - close(); - // console.info(`event published by ${url}`, ev); - } - }); - } -}); - -writeInput.addEventListener('input', () => { - publish.disabled = !writeInput.value.trimRight(); - updateElemHeight(writeInput); -}); -writeInput.addEventListener('blur', () => sendStatus.textContent = ''); - -function updateElemHeight(el) { - el.style.removeProperty('height'); - if (el.value) { - el.style.paddingBottom = 0; - el.style.paddingTop = 0; - el.style.height = el.scrollHeight + 'px'; - el.style.removeProperty('padding-bottom'); - el.style.removeProperty('padding-top'); - } -} - -// settings -const settingsForm = document.querySelector('form[name="settings"]'); -const privateKeyInput = settingsForm.querySelector('#privatekey'); -const pubKeyInput = settingsForm.querySelector('#pubkey'); -const statusMessage = settingsForm.querySelector('#keystatus'); -const generateBtn = settingsForm.querySelector('button[name="generate"]'); -const importBtn = settingsForm.querySelector('button[name="import"]'); -const privateTgl = settingsForm.querySelector('button[name="privatekey-toggle"]'); - -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; - 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 - } -}); - -function validKeys(privatekey, pubkey) { - 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.setAttribute('disabled', true); - return false; -} - -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"]'); -const profileSubmit = profileForm.querySelector('button[type="submit"]'); -const profileStatus = document.querySelector('#profilestatus'); -const onProfileError = err => { - profileStatus.hidden = false; - profileStatus.textContent = err.message -}; -profileForm.addEventListener('input', (e) => { - if (e.target.nodeName === 'TEXTAREA') { - updateElemHeight(e.target); - } - 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, - content: JSON.stringify(Object.fromEntries(form)), - tags: [], - created_at: Math.floor(Date.now() * 0.001), - }, {difficulty, statusElem: profileStatus, timeout}).catch(console.warn); - if (!newProfile) { - profileStatus.textContent = 'publishing profile data canceled'; - profileStatus.hidden = false; - return; - } - const privatekey = localStorage.getItem('private_key'); - const sig = await signEvent(newProfile, privatekey).catch(console.error); - if (sig) { - const ev = await pool.publish({...newProfile, sig}, (status, url) => { - if (status === 0) { - console.info(`publish request sent to ${url}`); - } - if (status === 1) { - profileStatus.textContent = 'profile metadata successfully published'; - profileStatus.hidden = false; - profileSubmit.disabled = true; - } - }).catch(console.error); - } -}); - -const errorOverlay = document.querySelector('#errorOverlay'); - -function promptError(error, options = {}) { - const {onAgain, onCancel} = 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') : '', - onAgain ? elem('button', {data: {action: 'again'}}, 'try again') : '', - ]), - ); - const handleOverlayClick = (e) => { - const button = e.target.closest('button'); - if (button) { - switch(button.dataset.action) { - case 'close': - onCancel(); - break; - case 'again': - onAgain(); - break; - } - errorOverlay.removeEventListener('click', handleOverlayClick); - errorOverlay.hidden = true; - unlockScroll(); - } - }; - errorOverlay.addEventListener('click', handleOverlayClick); - 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. - * 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'. - */ -function powEvent(evt, options) { - const {difficulty, statusElem, timeout} = options; - if (difficulty === 0) { - return Promise.resolve(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('canceled'); - }; - cancelBtn.addEventListener('click', onCancel); - - worker.onmessage = (msg) => { - worker.terminate(); - cancelBtn.removeEventListener('click', onCancel); - if (msg.data.error) { - promptError(msg.data.error, { - onCancel: () => reject('canceled'), - onAgain: async () => { - const result = await powEvent(evt, {difficulty, statusElem, timeout}).catch(console.warn); - resolve(result); - } - }) - } else { - resolve(msg.data.event); - } - }; - - worker.onerror = (err) => { - worker.terminate(); - cancelBtn.removeEventListener('click', onCancel); - reject(err); - }; - - worker.postMessage({event: evt, difficulty, timeout}); - }); -} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..487b5f4 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,294 @@ +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 {sub24hFeed, subNote, subProfile} from './subscriptions' +import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt} from './events'; +import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view'; +import {closeSettingsView, config, toggleSettingsView} from './settings'; +import {handleReaction, handleUpvote} from './reactions'; +import {closePublishView, openWriteInput, togglePublishView} from './write'; +import {handleMetadata, renderProfile} from './profiles'; +import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes'; +import {createTextNote, renderRecommendServer} 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) + .filter(note => note.id === view.id) + .forEach(renderNote); + break; + case 'profile': + const isEvent = (evt?: T): evt is T => evt !== undefined; + [ + ...textNoteList + .filter(note => note.pubkey === view.id), + ...replyList.filter(reply => reply.pubkey === view.id) + .map(reply => textNoteList.find(note => note.id === reply.replyTo) || replyList.find(note => note.id === reply.replyTo) ) + .filter(isEvent) + ] + .sort(sortByCreatedAt) + .reverse() + .forEach(renderNote); // render in-reply-to + + renderProfile(view.id); + 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) { // root article has not been rendered + return; + } + let replyContainer = parent.querySelector('.mobx-replies'); + if (!replyContainer) { + replyContainer = elem('div', {className: 'mobx-replies'}); + parent.append(replyContainer); + } + 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) { + console.warn('expected to find reply-to-event-id', evt); + 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: just push? + } 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(); + } +}; + +config.rerenderFeed = () => { + clearView(); + renderFeed(); +}; + +const handleRecommendServer = (evt: Event, relay: string) => { + if (getViewElem(evt.id) || !isWssUrl(evt.content)) { + return; + } + const art = renderRecommendServer(evt, relay); + if (textNoteList.length < 2) { + getViewContent().append(art); + } else { + const closestTextNotes = textNoteList + // TODO: prob change to hasEnoughPOW + .filter(note => !config.filterDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && Number(commitment) >= config.filterDifficulty)) + .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 + } + setViewElem(evt.id, art); +}; + +const onEvent = (evt: Event, relay: string) => { + switch (evt.kind) { + case 0: + handleMetadata(evt, relay); + break; + case 1: + handleTextNote(evt, relay); + break; + case 2: + handleRecommendServer(evt, relay); + break; + case 3: + // handleContactList(evt, relay); + break; + case 7: + handleReaction(evt, relay); + default: + // console.log(`TODO: add support for event kind ${evt.kind}`/*, evt*/) + } +}; + +// subscribe and change view +const route = (path: string) => { + if (path === '/') { + sub24hFeed(onEvent); + view('/', {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; + case 'npub': + subProfile(data, onEvent); + view(path, {type: 'profile', id: data}); + break; + default: + console.warn(`type ${type} not yet supported`); + } + renderFeed(); + } +}; + +// onload +route(location.pathname); +history.pushState({}, '', location.pathname); + +window.addEventListener('popstate', (event) => { + route(location.pathname); +}); + +const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => { + const href = a.getAttribute('href'); + if (typeof href !== 'string') { + console.warn('expected anchor to have href attribute', a); + return; + } + closeSettingsView(); + closePublishView(); + if (href === location.pathname) { + e.preventDefault(); + return; + } + if ( + href === '/' + || href.startsWith('/note') + || href.startsWith('/npub') + ) { + route(href); + history.pushState({}, '', href); + e.preventDefault(); + } +}; + +const handleButton = (button: HTMLButtonElement) => { + switch(button.name) { + case 'settings': + toggleSettingsView(); + return; + case 'new-note': + togglePublishView(); + return; + case 'back': + closePublishView(); + return; + } + const id = (button.closest('[data-id]') as HTMLElement)?.dataset.id; + if (id) { + switch(button.name) { + case 'reply': + openWriteInput(button, id); + break; + case 'star': + const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); + note && handleUpvote(note); + break; + } + } + // const container = e.target.closest('[data-append]'); + // if (container) { + // container.append(...parseTextContent(container.dataset.append)); + // delete container.dataset.append; + // return; + // } +}; + +document.body.addEventListener('click', (event: MouseEvent) => { + const target = event.target as HTMLElement; + const a = target?.closest('a'); + if (a) { + // dont intercept command-click + if (event.metaKey) { + return; + } + handleLink(a, event); + return; + } + const button = target?.closest('button'); + if (button) { + handleButton(button); + } +}); diff --git a/src/media.ts b/src/media.ts new file mode 100644 index 0000000..5653aa6 --- /dev/null +++ b/src/media.ts @@ -0,0 +1,114 @@ +import { elem } from './utils/dom'; +import { getNoxyUrl } from './utils/url'; + +export const parseContent = (content: string): unknown => { + try { + return JSON.parse(content); + } catch(err) { + console.warn(err, content); + return null; + } +} + +type FetchItem = { + href: string; + id: string; + relay: string; +}; + +type NoxyData = { + title: string; + descr: string; + images: string[]; +}; + +const fetchQue: Array = []; + +let fetchPending: (null | Promise) = null; + +const fetchNext = ( + href: string, + id: string, + relay: string, +) => { + const noxy = getNoxyUrl('meta', href, id, relay); + if (!noxy) { + return false; + } + const previewId = noxy.searchParams.toString(); + if (fetchPending) { + fetchQue.push({href, id, relay}); + return previewId; + } + fetchPending = fetch(noxy.href) + .then(data => { + if (data.status === 200) { + return data.json(); + } + // fetchQue.push({href, id, relay}); // could try one more time + return Promise.reject(data); + }) + .then(meta => { + const container = document.getElementById(previewId); + const content: Array = []; + if (meta.images[0]) { + const img = getNoxyUrl('data', meta.images[0], id, relay); + img && content.push( + elem('img', { + className: 'preview-image', + loading: 'lazy', + src: img.href, + }) + ); + } + if (meta.title) { + content.push(elem('h2', {className: 'preview-title'}, meta.title)); + } + if (meta.descr) { + content.push(elem('p', {className: 'preview-descr'}, meta.descr)) + } + if (container && content.length) { + container.append(elem('a', {href, rel: 'noopener noreferrer', target: '_blank'}, content)); + container.classList.add('preview-loaded'); + } + }) + .finally(() => { + fetchPending = null; + if (fetchQue.length) { + const {href, id, relay} = fetchQue.shift() as FetchItem; + return fetchNext(href, id, relay); + } + }) + .catch(err => err.text && err.text()) + .then(errMsg => errMsg && console.warn(errMsg)); + + return previewId; +}; + +export const linkPreview = ( + href: string, + id: string, + relay: string, +) => { + if ((/\.(gif|jpe?g|png)$/i).test(href)) { + const img = getNoxyUrl('data', href, id, relay); + if (!img) { + return null; + } + return elem('div', {}, + [elem('img', { + className: 'preview-image-only', + loading: 'lazy', + src: img.href, + })] + ); + } + const previewId = fetchNext(href, id, relay); + if (!previewId) { + return null; + } + return elem('div', { + className: 'preview', + id: previewId, + }); +}; diff --git a/src/notes.ts b/src/notes.ts new file mode 100644 index 0000000..b94dfce --- /dev/null +++ b/src/notes.ts @@ -0,0 +1,16 @@ +import {Event} from 'nostr-tools'; + +export type EventWithNip19 = Event & { + nip19: { + note: string; + npub: string; + } +}; + +export const textNoteList: Array = []; // could use indexDB + +export type EventWithNip19AndReplyTo = EventWithNip19 & { + replyTo: string; +}; + +export const replyList: Array = []; diff --git a/src/profiles.ts b/src/profiles.ts new file mode 100644 index 0000000..1c64302 --- /dev/null +++ b/src/profiles.ts @@ -0,0 +1,179 @@ +import {Event} from 'nostr-tools'; +import {elem, elemCanvas} from './utils/dom'; +import {getHost, getNoxyUrl} from './utils/url'; +import {getViewContent, getViewElem} from './view'; +import {validatePow} from './events'; +import {parseContent} from './media'; + +type Metadata = { + name?: string; + about?: string; + picture?: string; +}; + +type Profile = { + metadata: { + [relay: string]: Metadata; + }; + name?: string; + picture?: string; + pubkey: string; +}; + +const userList: Array = []; +// const tempContactList = {}; + +const setMetadata = ( + evt: Event, + relay: string, + metadata: Metadata, +) => { + let user = userList.find(u => u.pubkey === evt.pubkey); + if (!user) { + user = { + metadata: {[relay]: metadata}, + pubkey: evt.pubkey, + }; + userList.push(user); + } else { + user.metadata[relay] = { + ...user.metadata[relay], + // timestamp: evt.created_at, + ...metadata, + }; + } + + // store the first seen name (for now) as main user.name + if (!user.name && metadata.name) { + user.name = metadata.name; + } + + // use the first seen profile pic (for now), pics from different relays are not supported yet + if (!user.picture && metadata.picture) { + const imgUrl = getNoxyUrl('data', metadata.picture, evt.id, relay); + if (imgUrl) { + user.picture = imgUrl.href; + + // update profile images that used some nip-13 work + if (imgUrl.href && validatePow(evt)) { + document.body + .querySelectorAll(`canvas[data-pubkey="${evt.pubkey}"]`) + .forEach(canvas => canvas.parentNode?.replaceChild(elem('img', {src: imgUrl.href}), canvas)); + } + } + } + + // update profile names + const name = user.metadata[relay].name || user.name || ''; + if (name) { + document.body + // TODO: this should not depend on specific DOM structure, move pubkey info on username element + .querySelectorAll(`[data-pubkey="${evt.pubkey}"] > .mbox-body > header .mbox-username:not(.mbox-kind0-name)`) + .forEach((username: HTMLElement) => { + username.textContent = name; + username.classList.add('mbox-kind0-name'); + }); + } + // if (tempContactList[relay]) { + // const updates = tempContactList[relay].filter(update => update.pubkey === evt.pubkey); + // if (updates) { + // console.log('TODO: add contact list (kind 3)', updates); + // } + // } +}; + +export const handleMetadata = (evt: Event, relay: string) => { + const content = parseContent(evt.content); + if (!content || typeof content !== 'object' || Array.isArray(content)) { + console.warn('expected nip-01 JSON object with user info, but got something funny', evt); + return; + } + const hasNameString = 'name' in content && typeof content.name === 'string'; + const hasAboutString = 'about' in content && typeof content.about === 'string'; + const hasPictureString = 'picture' in content && typeof content.picture === 'string'; + // custom + const hasDisplayName = 'display_name' in content && typeof content.display_name === 'string'; + if (!hasNameString && !hasAboutString && !hasPictureString && !hasDisplayName) { + console.warn('expected basic nip-01 user info (name, about, picture) but nothing found', evt); + return; + } + const metadata: Metadata = { + ...(hasNameString && {name: content.name as string} || hasDisplayName && {name: content.display_name as string}), + ...(hasAboutString && {about: content.about as string}), + ...(hasPictureString && {picture: content.picture as string}), + }; + setMetadata(evt, relay, metadata); +}; + +export const getProfile = (pubkey: string) => userList.find(user => user.pubkey === pubkey); + +export const getMetadata = (evt: Event, relay: string) => { + const host = getHost(relay); + const user = getProfile(evt.pubkey); + const userImg = user?.picture; + const name = user?.metadata[relay]?.name || user?.name; + const userName = name || evt.pubkey.slice(0, 8); + const userAbout = user?.metadata[relay]?.about || ''; + const img = (userImg && validatePow(evt)) ? elem('img', { + alt: `${userName} ${host}`, + loading: 'lazy', + src: userImg, + title: `${userName} on ${host} ${userAbout}`, + }) : elemCanvas(evt.pubkey); + const time = new Date(evt.created_at * 1000); + return {host, img, name, time, userName}; +}; + +/* export function handleContactList(evt, relay) { + if (getViewElem(evt.id)) { + return; + } + const art = renderUpdateContact(evt, relay); + if (textNoteList.length < 2) { + getViewContent().append(art); + return; + } + const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at)); + getViewElem(closestTextNotes[0].id).after(art); + setViewElem(evt.id, art); + // const user = userList.find(u => u.pupkey === evt.pubkey); + // if (user) { + // console.log(`TODO: add contact list for ${evt.pubkey.slice(0, 8)} on ${relay}`, evt.tags); + // } else { + // tempContactList[relay] = tempContactList[relay] + // ? [...tempContactList[relay], evt] + // : [evt]; + // } +} */ + +// function renderUpdateContact(evt, relay) { +// const {img, time, userName} = getMetadata(evt, relay); +// const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ +// elem('header', {className: 'mbox-header'}, [ +// elem('small', {}, []), +// ]), +// elem('pre', {title: JSON.stringify(evt.content)}, [ +// elem('strong', {}, userName), +// ' updated contacts: ', +// JSON.stringify(evt.tags), +// ]), +// ]); +// return renderArticle([img, body], {className: 'mbox-updated-contact', data: {id: evt.id, pubkey: evt.pubkey, relay}}); +// } + +export const renderProfile = (id: string) => { + const content = getViewContent(); + const header = getViewElem(id); + if (!content || !header) { + return; + } + const profile = getProfile(id); + if (profile && profile.name) { + const h1 = header.querySelector('h1'); + if (h1) { + h1.textContent = profile.name; + } else { + header.prepend(elem('h1', {}, profile.name)); + } + } +}; \ No newline at end of file diff --git a/src/reactions.ts b/src/reactions.ts new file mode 100644 index 0000000..b63d846 --- /dev/null +++ b/src/reactions.ts @@ -0,0 +1,105 @@ +import {Event, signEvent, UnsignedEvent} from 'nostr-tools'; +import {powEvent} from './system'; +import {publish} from './relays'; +import {hasEventTag} from './events'; +import {getViewElem} from './view'; +import {config} from './settings'; + +type ReactionMap = { + [eventId: string]: Array +}; + +const reactionMap: ReactionMap = {}; + +export const getReactions = (eventId: string) => reactionMap[eventId] || []; + +export const getReactionContents = (eventId: string) => { + return reactionMap[eventId]?.map(({content}) => content) || []; +}; + +export const handleReaction = ( + evt: Event, + relay: string, +) => { + // last id is the note that is being reacted to https://github.com/nostr-protocol/nips/blob/master/25.md + const lastEventTag = evt.tags.filter(hasEventTag).at(-1); + if (!lastEventTag || !evt.content.length) { + // ignore reactions with no content + return; + } + const [, eventId] = lastEventTag; + if (reactionMap[eventId]) { + if (reactionMap[eventId].find(reaction => reaction.id === evt.id)) { + // already received this reaction from a different relay + return; + } + reactionMap[eventId] = [evt, ...(reactionMap[eventId])]; + } else { + reactionMap[eventId] = [evt]; + } + const article = getViewElem(eventId); + if (article) { + const button = article.querySelector('button[name="star"]') as HTMLButtonElement; + const reactions = button.querySelector('[data-reactions]') as HTMLElement; + reactions.textContent = `${reactionMap[eventId].length || ''}`; + if (evt.pubkey === config.pubkey) { + const star = button.querySelector('img[src*="star"]'); + star?.setAttribute('src', '/assets/star-fill.svg'); + star?.setAttribute('title', getReactionContents(eventId).join(' ')); + } + } +}; + +const upvote = async ( + eventId: string, + evt: UnsignedEvent, +) => { + const article = getViewElem(eventId); + const reactionBtn = article.querySelector('button[name="star"]') as HTMLButtonElement; + const statusElem = article.querySelector('[data-reactions]') as HTMLElement; + reactionBtn.disabled = true; + const newReaction = await powEvent(evt, { + difficulty: config.difficulty, + statusElem, + timeout: config.timeout, + }).catch(console.warn); + if (!newReaction) { + statusElem.textContent = `${getReactions(eventId)?.length}`; + reactionBtn.disabled = false; + return; + } + const privatekey = localStorage.getItem('private_key'); + if (!privatekey) { + statusElem.textContent = 'no private key to sign'; + statusElem.hidden = false; + return; + } + const sig = signEvent(newReaction, privatekey); + // TODO: validateEvent + if (sig) { + statusElem.textContent = 'publishing…'; + publish({...newReaction, sig}, (relay, error) => { + if (error) { + return console.error(error, relay); + } + console.info(`event published by ${relay}`); + }); + reactionBtn.disabled = false; + } +}; + +export const handleUpvote = (evt: Event) => { + const tags = [ + ...evt.tags + .filter(tag => ['e', 'p'].includes(tag[0])) // take e and p tags from event + .map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields, TODO: use relay? + ['e', evt.id], ['p', evt.pubkey], // last e and p tag is the id and pubkey of the note being reacted to (nip-25) + ]; + upvote(evt.id, { + kind: 7, + pubkey: config.pubkey, + content: '+', + tags, + created_at: Math.floor(Date.now() * 0.001), + }); +}; diff --git a/src/relays.ts b/src/relays.ts new file mode 100644 index 0000000..66b7250 --- /dev/null +++ b/src/relays.ts @@ -0,0 +1,99 @@ +import {Event, Filter, relayInit, Relay, Sub} from 'nostr-tools'; + +type SubCallback = ( + event: Readonly, + relay: Readonly, +) => void; + +type Subscribe = { + cb: SubCallback; + filter: Filter; +}; + +const relayList: Array = []; +const subList: Array = []; +const currentSubList: Array = []; + +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)); + relayList.push(relay); + } catch { + console.warn(`could not connect to ${url}`); + } +}; + +const unsubscribe = (sub: Sub) => { + sub.unsub(); + subList.splice(subList.indexOf(sub), 1); +}; + +const subscribe = ( + cb: SubCallback, + filter: Filter, + relay: Relay, +) => { + const sub = relay.sub([filter]); + subList.push(sub); + sub.on('event', (event: Event) => { + cb(event, relay.url); + }); + sub.on('eose', () => { + // console.log('eose', relay.url); + // unsubscribe(sub); + }); +}; + +const subscribeAll = ( + cb: SubCallback, + filter: Filter, +) => { + relayList.forEach(relay => subscribe(cb, filter, relay)); +}; + +export const sub = (obj: Subscribe) => { + currentSubList.push(obj); + subscribeAll(obj.cb, obj.filter); +}; + +export const unsubAll = () => { + subList.forEach(unsubscribe); + currentSubList.length = 0; +}; + +type PublishCallback = ( + relay: string, + errorMessage?: string, +) => void; + +export const publish = ( + event: Event, + cb: PublishCallback, +) => { + relayList.forEach(relay => { + 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'); // good one +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/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..05edefe --- /dev/null +++ b/src/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 metadata successfully published'; + profileStatus.hidden = false; + profileSubmit.disabled = true; + }); + } +}); diff --git a/src/cards.css b/src/styles/cards.css similarity index 86% rename from src/cards.css rename to src/styles/cards.css index 006ee5a..b94c656 100644 --- a/src/cards.css +++ b/src/styles/cards.css @@ -1,19 +1,13 @@ /* https://developer.mozilla.org/en-US/docs/Web/CSS/Layout_cookbook/Media_objects */ .mbox { - --profileimg-size: 4rem; - --profileimg-size-half: 2rem; - --profileimg-size-quarter: 1rem; align-items: center; display: flex; flex-direction: row; + flex-shrink: 0; flex-wrap: wrap; margin-bottom: 1rem; - padding: 0 var(--gap); -} -@media (orientation: portrait) { - .mbox { - padding: 0 var(--gap-half); - } + max-width: var(--content-width); + padding: 0 var(--gap-half); } .mbox:last-child { margin-bottom: 0; @@ -28,10 +22,10 @@ border-radius: var(--profileimg-size); flex-basis: var(--profileimg-size); height: var(--profileimg-size); - margin-right: 1.5rem; + margin-right: var(--gap-half); max-height: var(--profileimg-size); max-width: var(--profileimg-size); - overflow: hidden; + overflow: clip; position: relative; z-index: 2; } @@ -53,19 +47,14 @@ word-break: break-word; } .mbox-img + .mbox-body { - flex-basis: calc(100% - 64px - 1rem); + flex-basis: calc(100% - var(--profileimg-size) - var(--gap-half)); } .mbox-header { - flex-basis: calc(100% - 64px - 1rem); - flex-grow: 0; - flex-shrink: 1; margin-top: 0; } -.mbox-header time, -.mbox-username { - color: var(--color-accent); - cursor: pointer; +.mbox-header a { + font-size: var(--font-small); } .mbox-kind0-name { @@ -90,7 +79,7 @@ } .mbox { - overflow: hidden; + overflow: clip; } .mbox .mbox { overflow: visible; @@ -126,21 +115,21 @@ display: block; height: 200vh; left: var(--profileimg-size-half); - margin-left: -.2rem; + margin-left: -.1rem; position: absolute; top: -200vh; - width: .4rem; + width: .2rem; } .mobx-replies .mbox .mbox::before { background: none; border-color: var(--bgcolor-inactive);; border-style: solid; - border-width: 0 0 .4rem .4rem; + border-width: 0 0 .2rem .2rem; content: ""; display: block; height: var(--profileimg-size-quarter); left: calc(-1 * var(--profileimg-size-quarter)); - margin-left: -.2rem; + margin-left: -.1rem; position: absolute; top: 0; width: .8rem; @@ -152,10 +141,10 @@ display: block; height: 100vh; left: calc(-1 * var(--profileimg-size-quarter)); - margin-left: -.2rem; + margin-left: -.1rem; position: absolute; top: -100vh; - width: .4rem; + width: .2rem; } /* support visualisation of 3 levels of thread nesting, rest render flat without line */ .mbox .mobx-replies .mobx-replies::before, diff --git a/src/error.css b/src/styles/error.css similarity index 94% rename from src/error.css rename to src/styles/error.css index d3cd89a..bd70ad6 100644 --- a/src/error.css +++ b/src/styles/error.css @@ -29,7 +29,7 @@ } #errorOverlay .buttons { - max-width: var(--max-width); + max-width: var(--content-width); } @media (orientation: portrait) { #errorOverlay .buttons { diff --git a/src/form.css b/src/styles/form.css similarity index 95% rename from src/form.css rename to src/styles/form.css index 200476e..370e027 100644 --- a/src/form.css +++ b/src/styles/form.css @@ -8,7 +8,8 @@ form, --padding: 1.2rem; display: flex; flex-direction: column; - padding: var(--gap); + max-width: var(--content-width); + padding: 0 var(--gap); } fieldset { @@ -21,7 +22,7 @@ legend { display: none; width: 100%; } -#newMessage legend { +#newNote legend { display: block; } @@ -82,17 +83,17 @@ textarea { textarea:focus { min-height: 3.5rem; } -#newMessage textarea { +#newNote textarea { min-height: 10rem; } -#newMessage textarea:focus { +#newNote textarea:focus { min-height: 18rem; } @media (orientation: portrait) { - #newMessage textarea { + #newNote textarea { min-height: 8rem; } - #newMessage textarea:focus { + #newNote textarea:focus { min-height: 15rem; } } @@ -235,7 +236,7 @@ button#publish { button[name="back"] { display: none; } -#newMessage button[name="back"] { +#newNote button[name="back"] { align-self: end; display: inherit; } diff --git a/src/main.css b/src/styles/main.css similarity index 82% rename from src/main.css rename to src/styles/main.css index 4d78b6f..bd32aed 100644 --- a/src/main.css +++ b/src/styles/main.css @@ -1,13 +1,14 @@ -@import "tabs.css"; +@import "view.css"; @import "cards.css"; @import "form.css"; @import "write.css"; @import "error.css"; :root { + --content-width: min(100% - 2.4rem, 96ch); /* 5px auto Highlight */ --focus-border-color: rgb(0, 122, 255); - --focus-border-radius: 2px; + --focus-border-radius: .2rem; --focus-outline-color: rgb(192, 227, 252); --focus-outline-offset: 2px; --focus-outline-style: solid; @@ -16,7 +17,9 @@ --font-small: 1.2rem; --gap: 2.4rem; --gap-half: 1.2rem; - --max-width: 96ch; + --profileimg-size: 4rem; + --profileimg-size-half: 2rem; + --profileimg-size-quarter: 1rem; } ::selection { @@ -30,7 +33,8 @@ @media (prefers-color-scheme: light) { html { - --bgcolor: #fdfefa; + --bgcolor: #fff; + --bgcolor-nav: gainsboro; --bgcolor-accent: #7badfc; --bgcolor-danger: rgb(225, 40, 40); --bgcolor-danger-input: rgba(255 255 255 / .85); @@ -45,6 +49,7 @@ @media (prefers-color-scheme: dark) { html { --bgcolor: #191919; + --bgcolor-nav: darkslateblue; --bgcolor-accent: rgb(16, 93, 176); --bgcolor-danger: rgb(169, 0, 0); --bgcolor-danger-input: rgba(0 0 0 / .5); @@ -74,6 +79,12 @@ body { color: var(--color); font-size: 1.6rem; line-height: 1.5; + word-break: break-all; +} + +html, body { + min-height: 100%; + height: 100%; margin: 0; } @@ -98,7 +109,7 @@ img { } .text { - margin: var(--gap); + padding: 0 var(--gap); } .danger { @@ -114,9 +125,11 @@ a:focus { outline: var(--focus-outline); outline-offset: 0; } - a:visited { - color: darkmagenta; + color: darkslateblue; +} +nav a:visited { + color: inherit; } img[alt] { diff --git a/src/styles/view.css b/src/styles/view.css new file mode 100644 index 0000000..2b2004f --- /dev/null +++ b/src/styles/view.css @@ -0,0 +1,132 @@ +.root { + display: flex; + height: 100%; + max-height: 100%; + flex-direction: column; +} +@media (orientation: landscape) { + .root { + flex-direction: row-reverse; + } +} + +main { + display: flex; + flex-grow: 1; + height: 100%; + overflow: clip; + position: relative; + width: 100%; +} + +aside { + z-index: 4; +} + +nav { + background-color: var(--bgcolor-nav); + display: flex; + flex-direction: row; + flex-grow: 1; + flex-shrink: 0; + justify-content: space-between; + overflow-y: auto; + padding: 0 1.5rem; + user-select: none; + -webkit-user-select: none; +} +@supports (padding: max(0px)) { + nav { + padding-bottom: env(safe-area-inset-bottom); + } +} +@media (orientation: landscape) { + nav { + flex-direction: column; + justify-content: space-between; + } +} +nav a, +nav button { + --bgcolor-accent: transparent; + --border-color: transparent; + border-radius: 0; + padding: 1rem; +} +@media (orientation: landscape) { + nav a, + nav button { + padding: 2rem 0; + } +} + +.view { + background-color: var(--bgcolor); + display: flex; + flex-direction: column; + left: 0; + min-height: 100%; + opacity: 1; + overflow-x: clip; + position: absolute; + top: 0; + transform: translateX(0); + transition: transform .3s cubic-bezier(.465,.183,.153,.946); + width: 100%; + will-change: transform; + z-index: 2; +} +@media (orientation: landscape) { + .view { + transition: opacity .3s cubic-bezier(.465,.183,.153,.946); + } +} +.view.view-next { + z-index: 3; +} +.view.view-prev { + z-index: 1; +} +@media (orientation: portrait) { + .view.view-next { + transform: translateX(100%); + } + .view.view-prev { + transform: translateX(-20%); + } +} +@media (orientation: landscape) { + .view.view-next, + .view.view-next { + opacity: 0; + pointer-events: none; + } +} + +.content { + display: flex; + flex-direction: column; + flex-grow: 1; + margin-inline: auto; + overflow-y: auto; + padding: var(--gap-half) 0 0 0; + width: 100%; +} +main .content { + height: 1px; +} +nav .content { + display: flex; + flex-direction: row; + justify-content: space-between; +} +nav a { + display: flex; + flex-direction: column; + text-align: center; + text-decoration: none; +} + +.content > header { + padding: 3rem 3rem 3rem calc(var(--profileimg-size) + var(--gap)); +} diff --git a/src/write.css b/src/styles/write.css similarity index 67% rename from src/write.css rename to src/styles/write.css index d185f56..ab46ef4 100644 --- a/src/write.css +++ b/src/styles/write.css @@ -1,20 +1,29 @@ #bubble { - bottom: 4rem; + background-color: darkmagenta; + border-color: darkmagenta; + border-radius: 10rem; + bottom: 8rem; height: 10rem; padding: 0; position: fixed; - right: 5rem; + right: 8rem; width: 10rem; - z-index: 12; + z-index: 1; } @media (orientation: portrait) { #bubble { - bottom: calc(2 * var(--gap)); + bottom: calc(4 * var(--gap)); right: var(--gap); } } +#bubble svg { + height: 100%; + position: relative; + width: 100%; + top: .5rem; +} -#newMessage { +#newNote { align-items: center; display: flex; height: 100vh; @@ -25,12 +34,12 @@ z-index: 20; } @media (orientation: portrait) { - #newMessage { + #newNote { align-items: start; } } -#newMessage #writeForm { +#newNote #writeForm { align-items: start; background-color: var(--bgcolor); display: flex; @@ -46,11 +55,11 @@ padding: 2rem; } -#newMessage .form-inline textarea { +#newNote .form-inline textarea { flex-basis: 100%; margin: var(--gap) 0; } -#newMessage .buttons { +#newNote .buttons { align-self: end; } \ No newline at end of file diff --git a/src/subscriptions.ts b/src/subscriptions.ts new file mode 100644 index 0000000..19e63c1 --- /dev/null +++ b/src/subscriptions.ts @@ -0,0 +1,69 @@ +import {Event} from 'nostr-tools'; +import {sub, unsubAll} from './relays'; + +type SubCallback = ( + event: Event, + relay: string, +) => void; + +/** subscribe to global feed */ +export const sub24hFeed = (onEvent: SubCallback) => { + unsubAll(); + sub({ + cb: onEvent, + filter: { + kinds: [0, 1, 2, 7], + // until: Math.floor(Date.now() * 0.001), + since: Math.floor((Date.now() * 0.001) - (24 * 60 * 60)), + limit: 50, + } + }); +}; + +/** 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, + } + }); + sub({ + cb: onEvent, + filter: { + '#e': [eventId], + kinds: [1, 7], + } + }); +}; + +/** subscribe to npub key (nip-19) */ +export const subProfile = ( + pubkey: string, + onEvent: SubCallback, +) => { + unsubAll(); + sub({ + cb: onEvent, + filter: { + authors: [pubkey], + kinds: [0], + limit: 1, + } + }); + // get notes for profile + sub({ + cb: onEvent, + filter: { + authors: [pubkey], + kinds: [1], + limit: 50, + } + }); +}; diff --git a/src/system.ts b/src/system.ts new file mode 100644 index 0000000..832b076 --- /dev/null +++ b/src/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/src/tabs.css b/src/tabs.css deleted file mode 100644 index 9938ecc..0000000 --- a/src/tabs.css +++ /dev/null @@ -1,76 +0,0 @@ -.tabs { - flex-basis: 100%; - margin-top: 4rem; -} -.tabs .tab-content { display: none; } -#feed:checked ~ .tabs .tab-content:nth-child(1), -#trending:checked ~ .tabs .tab-content:nth-child(2), -#direct:checked ~ .tabs .tab-content:nth-child(3), -#chat:checked ~ .tabs .tab-content:nth-child(4), -#settings:checked ~ .tabs .tab-content:nth-child(5) { display: block; } - -input[type="radio"].tab { - clip: rect(0, 0, 0, 0); - height: 0; - overflow: hidden; - position: absolute; - width: 0; -} - -.tab + label { - background-color: var(--bgcolor-textinput); - border: none; - color: var(--color); - display: inline-block; - margin-left: var(--gap); - margin-top: var(--gap); - outline: 2px solid var(--bgcolor-accent); - padding: 1rem 1.5em; - position: relative; - top: 1px; - z-index: 11; -} -input[type="radio"]:checked + label { - background: var(--bgcolor-accent); -} - -.tab:focus + label, -.tab:active + label { - border-color: var(--focus-border-color); - border-radius: var(--focus-border-radius); - outline: var(--focus-outline); - outline-offset: var(--focus-outline-offset); -} - -.tab-content { - max-width: var(--max-width); - min-height: 200px; - padding: var(--gap-half) 0 100px 0; -} -.tabbed { - align-items: start; - display: flex; - flex-wrap: wrap; -} -@media (orientation: portrait) { - .tabbed { - align-items: start; - display: flex; - flex-direction: row-reverse; - flex-wrap: wrap; - justify-content: start; - } - .tabs { - height: 100vh; - height: 100dvh; - margin-top: 0; - order: 1; - overflow: scroll; - width: 100vw; - } - .tab + label { - margin-top: calc(-3 * var(--gap)); - margin-left: var(--gap); - order: 2; - } -} \ No newline at end of file diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..862b5a6 --- /dev/null +++ b/src/ui.ts @@ -0,0 +1,84 @@ +import {Event} from 'nostr-tools'; +import {elem, elemArticle, parseTextContent} from './utils/dom'; +import {dateTime, formatTime} from './utils/time'; +import {validatePow, sortByCreatedAt} from './events'; +import {setViewElem} from './view'; +import {config} from './settings'; +import {getReactions, getReactionContents} from './reactions'; +import {openWriteInput} from './write'; +import {linkPreview} from './media'; +import {getMetadata} from './profiles'; +import {EventWithNip19, replyList} from './notes'; + +setInterval(() => { + document.querySelectorAll('time[datetime]').forEach((timeElem: HTMLTimeElement) => { + timeElem.textContent = formatTime(new Date(timeElem.dateTime)); + }); +}, 10000); + +export const createTextNote = ( + evt: EventWithNip19, + relay: string, +) => { + const {host, img, name, time, userName} = getMetadata(evt, relay); + const replies = replyList.filter(({replyTo}) => replyTo === evt.id); + // const isLongContent = evt.content.trimRight().length > 280; + // const content = isLongContent ? evt.content.slice(0, 280) : evt.content; + const reactions = getReactions(evt.id); + const didReact = reactions.length && !!reactions.find(reaction => reaction.pubkey === config.pubkey); + const [content, {firstLink}] = parseTextContent(evt.content); + const buttons = elem('div', {className: 'buttons'}, [ + elem('button', {name: 'reply', type: 'button'}, [ + elem('img', {height: 24, width: 24, src: '/assets/comment.svg'}) + ]), + elem('button', {name: 'star', type: 'button'}, [ + elem('img', { + alt: didReact ? '✭' : '✩', // ♥ + height: 24, width: 24, + src: `/assets/${didReact ? 'star-fill' : 'star'}.svg`, + title: getReactionContents(evt.id).join(' '), + }), + elem('small', {data: {reactions: ''}}, reactions.length || ''), + ]), + ]); + if (localStorage.getItem('reply_to') === evt.id) { + openWriteInput(buttons, evt.id); + } + const replyFeed: Array = replies[0] ? replies.sort(sortByCreatedAt).map(e => setViewElem(e.id, createTextNote(e, relay))) : []; + return elemArticle([ + elem('div', {className: 'mbox-img'}, img), + elem('div', {className: 'mbox-body'}, [ + elem('header', { + className: 'mbox-header', + title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${host}\n\nEvent-id: ${evt.id} + ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} + ${evt.content}` + }, [ + elem('a', {className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, href: `/${evt.nip19.npub}`}, name || userName), + ' ', + elem('a', {href: `/${evt.nip19.note}`}, elem('time', {dateTime: time.toISOString()}, formatTime(time))), + ]), + elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ + ...content, + (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : null, + ]), + buttons, + ]), + ...(replies[0] ? [elem('div', {className: 'mobx-replies'}, replyFeed.reverse())] : []), + ], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); +}; + +export const renderRecommendServer = (evt: Event, relay: string) => { + const {img, name, time, userName} = getMetadata(evt, relay); + const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [ + elem('header', {className: 'mbox-header'}, [ + elem('small', {}, [ + elem('strong', {}, userName) + ]), + ]), + ` recommends server: ${evt.content}`, + ]); + return elemArticle([ + elem('div', {className: 'mbox-img'}, [img]), body + ], {className: 'mbox-recommend-server', data: {id: evt.id, pubkey: evt.pubkey}}); +}; diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index 5ba1f1a..0000000 --- a/src/utils.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * throttle and debounce given function in regular time interval, - * but with the difference that the last call will be debounced and therefore never missed. - * @param {*} function to throttle and debounce - * @param {*} time desired interval to execute function - * @returns callback - */ -export const bounce = (fn, time) => { - let throttle; - let debounce; - return (/*...args*/) => { - if (throttle) { - clearTimeout(debounce); - debounce = setTimeout(() => fn(/*...args*/), time); - return; - } - fn(/*...args*/); - throttle = setTimeout(() => { - throttle = false; - }, time); - }; -}; diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 0000000..db2a0d3 --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,8 @@ +/** + * type-guarded function that tells TypeScript (in strictNullChecks mode) that you're filtering out null/undefined items. + * example: array.filter(isNotNull) + */ +export const isNotNull = (item: T): item is NonNullable => item != null; + +// alternative +// const const isNotNull = (item: T | null): item is T => item !== null; diff --git a/src/cryptoutils.js b/src/utils/crypto.ts similarity index 63% rename from src/cryptoutils.js rename to src/utils/crypto.ts index 2c2b265..9cc1d5a 100644 --- a/src/cryptoutils.js +++ b/src/utils/crypto.ts @@ -1,19 +1,19 @@ /** - * 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) => { + * 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') { + 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' ) { + if (bits[b] === '1' ) { break; // reached non-zero bit; stop } count += 1; diff --git a/src/utils/dom.ts b/src/utils/dom.ts new file mode 100644 index 0000000..e550577 --- /dev/null +++ b/src/utils/dom.ts @@ -0,0 +1,191 @@ +import {isNotNull} from './array'; +import {isValidURL} from './url'; + +type DataAttributes = { + data: { + [key: string]: string | number; + }, +} & { + dataset: never, // the dataset property itself is readonly +}; + +type Attributes = Partial; + +type Children = Array | HTMLElement | string | number | null; + +/** + * example usage: + * + * const props = {className: 'btn', onclick: async (e) => alert('hi')}; + * const btn = elem('button', props, ['download']); + * document.body.append(btn); + * + * @param {string} name + * @param {HTMLElement.prototype} props + * @param {Array | string | number} children + * @return HTMLElement + */ +export const elem = ( + name: Extract, + attrs?: Attributes, + children?: Children, +): HTMLElementTagNameMap[Name] => { + const el = document.createElement(name); + if (attrs) { + const {data, ...props} = attrs; + Object.assign(el, props); + if (data) { + Object.entries(data).forEach(([key, value]) => { + el.dataset[key] = value as string; + }); + } + } + if (children != null) { + if (Array.isArray(children)) { + el.append(...children.filter(isNotNull)); + } else { + switch (typeof children) { + case 'number': + el.append(`${children}`); + break; + case 'string': + el.append(children); + break; + default: + if (children instanceof Element) { + el.append(children); + break; + } + console.error(`expected element, string or number but got ${typeof children}`, children); + } + } + } + return el; +}; + +/** freeze global page scrolling */ +export const lockScroll = () => document.body.style.overflow = 'hidden'; + +/** free global page scrolling */ +export const unlockScroll = () => document.body.style.removeProperty('overflow'); + +/** + * example usage: + * + * const [content, {firstLink}] = parseTextContent('Hi
click https://nostr.ch/'); + * + * @param {string} content + * @returns [Array, {firstLink: href}] + */ +export const parseTextContent = ( + content: string, +): [ + Array, + {firstLink: string | undefined}, +] => { + let firstLink: string | undefined; + const parsedContent = content + .trim() + .replaceAll(/\n{3,}/g, '\n\n') + .split('\n') + .map(line => { + const words = line.split(/\s/); + return words.map(word => { + if (word.match(/^ln(tbs?|bcr?t?)[a-z0-9]+$/g)) { + return elem('a', { + href: `lightning:${word}` + }, `lightning:${word.slice(0, 24)}…`); + } + if (!word.match(/^(https?:\/\/|www\.)\S*/)) { + return word; + } + try { + if (!word.startsWith('http')) { + word = 'https://' + word; + } + const url = new URL(word); + if (!isValidURL(url)) { + return word; + } + firstLink = firstLink || url.href; + return elem('a', { + href: url.href, + target: '_blank', + rel: 'noopener noreferrer' + }, url.href.slice(url.protocol.length + 2)); + } catch (err) { + return word; + } + }) + .reduce((acc, word) => [...acc, word, ' '], []); + }) + .reduce((acc, words) => [...acc, ...words, elem('br')], []); + + return [ + parsedContent, + {firstLink} + ]; +}; + +/** + * creates a small profile image + * @param text to pass pubkey + * @returns HTMLCanvasElement | null + */ +export const elemCanvas = (text: string) => { + const canvas = elem('canvas', { + height: 80, + width: 80, + data: {pubkey: text} + }); + const context = canvas.getContext('2d'); + if (!context) { + return null; + } + const color = `#${text.slice(0, 6)}`; + context.fillStyle = color; + context.fillRect(0, 0, 80, 80); + context.fillStyle = '#111'; + context.fillRect(0, 50, 80, 32); + context.font = 'bold 18px monospace'; + if (color === '#000000') { + context.fillStyle = '#fff'; + } + context.fillText(text.slice(0, 8), 2, 46); + return canvas; +}; + +/** + * creates a placeholder element that animates the height to 0 + * @param element to get the initial height from + * @returns HTMLDivElement + */ +export const elemShrink = (el: HTMLElement) => { + const height = el.style.height || el.getBoundingClientRect().height; + const shrink = elem('div', {className: 'shrink-out'}); + shrink.style.height = `${height}px`; + shrink.addEventListener('animationend', () => shrink.remove(), {once: true}); + return shrink; +}; + + +export const updateElemHeight = ( + el: HTMLInputElement | HTMLTextAreaElement +) => { + el.style.removeProperty('height'); + if (el.value) { + el.style.paddingBottom = '0'; + el.style.paddingTop = '0'; + el.style.height = el.scrollHeight + 'px'; + el.style.removeProperty('padding-bottom'); + el.style.removeProperty('padding-top'); + } +}; + +export const elemArticle = ( + content: Array, + attrs: Attributes = {} +) => { + const className = attrs.className ? ['mbox', attrs?.className].join(' ') : 'mbox'; + return elem('article', {...attrs, className}, content); +}; diff --git a/src/timeutil.js b/src/utils/time.ts similarity index 65% rename from src/timeutil.js rename to src/utils/time.ts index f798f83..8adf84b 100644 --- a/src/timeutil.js +++ b/src/utils/time.ts @@ -1,8 +1,34 @@ +/** + * throttle and debounce given function in regular time interval, + * but with the difference that the last call will be debounced and therefore never missed. + * @param {*} function to throttle and debounce + * @param {*} time desired interval to execute function + * @returns callback + */ +export const bounce = ( + fn: () => void, + time: number, +) => { + let throttle: number | undefined; + let debounce: number | undefined; + return (/*...args*/) => { + if (throttle) { + clearTimeout(debounce); + debounce = setTimeout(() => fn(/*...args*/), time); + return; + } + fn(/*...args*/); + throttle = setTimeout(() => { + clearTimeout(throttle); + }, time); + }; +}; + /** * Intl.DateTimeFormat object - * + * * example: - * + * * console.log(dateTime.format(new Date())); */ export const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */, { @@ -12,17 +38,20 @@ export const dateTime = new Intl.DateTimeFormat('de-ch' /* navigator.language */ /** * format time relative to now, such as 5min ago - * - * @param {Date} time + * + * @param {Date} time * @param {string} locale * @returns string - * + * * example: - * + * * console.log(timeAgo(new Date(Date.now() - 10000))); - * + * */ -const timeAgo = (time, locale = 'en') => { +const timeAgo = ( + time: Date, + locale: string = 'en', +) => { const relativeTime = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'long', @@ -55,7 +84,7 @@ const timeAgo = (time, locale = 'en') => { * @param {time} date object to format * @return string */ -export const formatTime = (time) => { +export const formatTime = (time: Date) => { const yesterday = new Date(Date.now() - (24 * 60 * 60 * 1000)); if (time > yesterday) { return timeAgo(time); diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000..4503605 --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,65 @@ +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; +}; + +export const isValidURL = (url: URL) => { + if (!['http:', 'https:'].includes(url.protocol)) { + return false; + } + if (!['', '443', '80'].includes(url.port)) { + return false; + } + if (url.hostname === 'localhost') { + return false; + } + const lastDot = url.hostname.lastIndexOf('.'); + if (lastDot < 1) { + return false; + } + if (url.hostname.slice(lastDot) === '.local') { + return false; + } + if (url.hostname.slice(lastDot + 1).match(/^[\d]+$/)) { // there should be no tld with numbers, possible ipv4 + return false; + } + if (url.hostname.includes(':')) { // possibly an ipv6 addr; certainly an invalid hostname + return false; + } + return true; +}; diff --git a/src/view.ts b/src/view.ts new file mode 100644 index 0000000..445b07f --- /dev/null +++ b/src/view.ts @@ -0,0 +1,102 @@ +import {elem} from './utils/dom'; + +type ViewOptions = { + type: 'feed' +} | { + type: 'note'; + id: string; +} | { + type: 'profile'; + id: string; +}; + +type DOMMap = { + [id: string]: HTMLElement +}; + +type Container = { + id: string; + options: ViewOptions, + view: HTMLElement; + content: HTMLDivElement; + dom: DOMMap; +}; + +const containers: Array = []; + +let activeContainerIndex = -1; + +export const getViewContent = () => containers[activeContainerIndex]?.content; + +export const clearView = () => { + // TODO: this is clears the current view, but it should probably do this for all views + const domMap = containers[activeContainerIndex]?.dom; + Object.keys(domMap).forEach(eventId => delete domMap[eventId]); + getViewContent().replaceChildren(); +}; + +export const getViewElem = (id: string) => { + return containers[activeContainerIndex]?.dom[id]; +}; + +export const setViewElem = (id: string, node: HTMLElement) => { + const container = containers[activeContainerIndex]; + if (container) { + container.dom[id] = node; + } + return node; +}; + +const mainContainer = document.querySelector('main') as HTMLElement; + +const createContainer = ( + route: string, + options: ViewOptions, +) => { + const content = elem('div', {className: 'content'}); + const dom: DOMMap = {}; + switch (options.type) { + case 'profile': + const header = elem('header', {}, + elem('small', {}, route) + ); + dom[options.id] = header; + content.append(header); + break; + case 'note': + break; + case 'feed': + break; + } + const view = elem('section', {className: 'view'}, [content]); + const container = {id: route, options, view, content, dom}; + mainContainer.append(view); + containers.push(container); + return container; +}; + +type GetViewOptions = () => ViewOptions; + +export const getViewOptions: GetViewOptions = () => containers[activeContainerIndex]?.options || {type: 'feed'}; + +export const view = ( + route: string, + options: ViewOptions, +) => { + const active = containers[activeContainerIndex]; + const nextContainer = containers.find(c => c.id === route) || createContainer(route, options); + const nextContainerIndex = containers.indexOf(nextContainer); + if (nextContainerIndex === activeContainerIndex) { + return; + } + if (active) { + nextContainer.view.classList.add('view-next'); + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + nextContainer.view.classList.remove('view-next', 'view-prev'); + }); + active?.view.classList.add(nextContainerIndex < activeContainerIndex ? 'view-next' : 'view-prev'); + activeContainerIndex = nextContainerIndex; + }); +}; diff --git a/src/worker.js b/src/worker.js index 726adb3..c9007b2 100644 --- a/src/worker.js +++ b/src/worker.js @@ -1,7 +1,7 @@ import {getEventHash} from 'nostr-tools'; -import {zeroLeadingBitsCount} from './cryptoutils.js'; +import {zeroLeadingBitsCount} from './utils/crypto'; -function mine(event, difficulty, timeout = 5) { +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}`); @@ -26,12 +26,12 @@ function mine(event, difficulty, timeout = 5) { const id = getEventHash(event); if (zeroLeadingBitsCount(id) === difficulty) { console.timeEnd('pow'); - return event; + return {id, ...event}; } } -} +}; -addEventListener('message', async (msg) => { +addEventListener('message', (msg) => { const {difficulty, event, timeout} = msg.data; try { const minedEvent = mine(event, difficulty, timeout); diff --git a/src/write.ts b/src/write.ts new file mode 100644 index 0000000..f551a3b --- /dev/null +++ b/src/write.ts @@ -0,0 +1,154 @@ +import {signEvent} from 'nostr-tools'; +import {elemShrink, updateElemHeight} from './utils/dom'; +import {powEvent} from './system'; +import {config} from './settings'; +import {publish} from './relays'; + +// form used to write and publish textnotes for replies and new notes +const writeForm = document.querySelector('#writeForm') as HTMLFormElement; +const writeInput = document.querySelector('textarea[name="message"]') as HTMLTextAreaElement; + +// overlay for writing new text notes +const publishView = document.querySelector('#newNote') as HTMLElement; + +const openWriteView = () => { + publishView.append(writeForm); + if (writeInput.value.trimRight()) { + writeInput.style.removeProperty('height'); + } + requestAnimationFrame(() => { + updateElemHeight(writeInput); + writeInput.focus(); + }); + publishView.removeAttribute('hidden'); +}; + +export const closePublishView = () => publishView.hidden = true; + +export const togglePublishView = () => { + if (publishView.hidden) { + localStorage.removeItem('reply_to'); // should it forget old replyto context? + openWriteView(); + } else { + publishView.hidden = true; + } +}; + +const appendReplyForm = (el: HTMLElement) => { + writeForm.before(elemShrink(writeInput)); + writeInput.blur(); + writeInput.style.removeProperty('height'); + el.after(writeForm); + if (writeInput.value && !writeInput.value.trimRight()) { + writeInput.value = ''; + } else { + requestAnimationFrame(() => updateElemHeight(writeInput)); + } + requestAnimationFrame(() => writeInput.focus()); +}; + +const closeWriteInput = () => writeInput.blur(); + +export const openWriteInput = ( + button: HTMLElement, + id: string, +) => { + appendReplyForm(button.closest('.buttons') as HTMLElement); + localStorage.setItem('reply_to', id); +}; + +export const toggleWriteInput = ( + button: HTMLElement, + id: string, +) => { + if (id && localStorage.getItem('reply_to') === id) { + closeWriteInput(); + return; + } + appendReplyForm(button.closest('.buttons') as HTMLElement); + localStorage.setItem('reply_to', id); +}; + +// const updateWriteInputHeight = () => updateElemHeight(writeInput); + +writeInput.addEventListener('focusout', () => { + const reply_to = localStorage.getItem('reply_to'); + if (reply_to && writeInput.value === '') { + writeInput.addEventListener('transitionend', (event) => { + if (!reply_to || reply_to === localStorage.getItem('reply_to') && !writeInput.style.height) { // should prob use some class or data-attr instead of relying on height + writeForm.after(elemShrink(writeInput)); + writeForm.remove(); + localStorage.removeItem('reply_to'); + } + }, {once: true}); + } +}); + +// document.body.addEventListener('keyup', (e) => { +// if (e.key === 'Escape') { +// hideNewMessage(true); +// } +// }); + +const sendStatus = document.querySelector('#sendstatus') as HTMLElement; +const publishBtn = document.querySelector('#publish') as HTMLButtonElement; +const onSendError = (err: Error) => sendStatus.textContent = err.message; + +writeForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const privatekey = localStorage.getItem('private_key'); + if (!config.pubkey || !privatekey) { + return onSendError(new Error('no pubkey/privatekey')); + } + const content = writeInput.value.trimRight(); + if (!content) { + return onSendError(new Error('message is empty')); + } + const replyTo = localStorage.getItem('reply_to'); + const close = () => { + sendStatus.textContent = ''; + writeInput.value = ''; + writeInput.style.removeProperty('height'); + publishBtn.disabled = true; + if (replyTo) { + localStorage.removeItem('reply_to'); + publishView.append(writeForm); + } + publishView.hidden = true; + }; + const tags = replyTo ? [['e', replyTo]] : []; // , eventRelayMap[replyTo][0] + const newEvent = await powEvent({ + kind: 1, + content, + pubkey: config.pubkey, + tags, + created_at: Math.floor(Date.now() * 0.001), + }, { + difficulty: config.difficulty, + statusElem: sendStatus, + timeout: config.timeout, + }).catch(console.warn); + if (!newEvent) { + close(); + return; + } + const sig = signEvent(newEvent, privatekey); + // TODO validateEvent + if (sig) { + sendStatus.textContent = 'publishing…'; + publish({...newEvent, sig}, (relay, error) => { + if (error) { + return console.log(error, relay); + } + console.info(`publish request sent to ${relay}`); + close(); + }); + } +}); + +writeInput.addEventListener('input', () => { + publishBtn.disabled = !writeInput.value.trimRight(); + updateElemHeight(writeInput); +}); + +writeInput.addEventListener('blur', () => sendStatus.textContent = ''); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ec17c1b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "alwaysStrict": true, + "moduleResolution": "node", + "noImplicitAny": true, + "noImplicitThis": true, + "strictBindCallApply": true, + "strictFunctionTypes": false, + "strictNullChecks": true, + "target": "es2022" + }, + "exclude": [ + "dist", + "esbuildconf.js", + "node_modules", + "**/*.test.ts" + ] +}