diff --git a/package-lock.json b/package-lock.json index 7f51552..d5ea9a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,12 @@ "name": "degmods.com", "version": "0.0.0", "dependencies": { - "@nostr-dev-kit/ndk": "2.8.2", + "@getalby/lightning-tools": "5.0.3", + "@nostr-dev-kit/ndk": "2.10.0", "@reduxjs/toolkit": "2.2.6", + "axios": "1.7.3", + "bech32": "2.0.0", + "buffer": "6.0.3", "date-fns": "3.6.0", "dompurify": "3.1.6", "file-saver": "2.0.5", @@ -17,14 +21,17 @@ "nostr-login": "1.5.2", "nostr-tools": "2.7.1", "papaparse": "5.4.1", + "qrcode.react": "3.1.0", "react": "^18.3.1", + "react-countdown": "2.3.5", "react-dom": "^18.3.1", "react-quill": "2.0.0", "react-redux": "9.1.2", "react-router-dom": "^6.24.1", "react-toastify": "10.0.5", "react-window": "1.8.10", - "uuid": "10.0.0" + "uuid": "10.0.0", + "webln": "0.3.2" }, "devDependencies": { "@types/dompurify": "3.0.5", @@ -903,6 +910,18 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@getalby/lightning-tools": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.0.3.tgz", + "integrity": "sha512-QG3/SBI5n2py5IgsjP3K+c8eq55eiI3PQB12yo9Pot0b5hcN7TNNoTKn0fgLJjO1iEVCUkF513kDOpjjXwK0hQ==", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1082,9 +1101,9 @@ } }, "node_modules/@nostr-dev-kit/ndk": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.8.2.tgz", - "integrity": "sha512-+dOEyuYvO5/MoI5iTi8C5HifmvfeEvpybNesluVYyu+o+koFdfc+WSYH050V8+9KlOgx8nOZAaqXnHz0KY1gBA==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.0.tgz", + "integrity": "sha512-TqCAAo6ylORraAXrzRkCGFN2xTMiFbdER8Y8CtUT0HwOpFG/Wn+PBNeDeDmqkl/6LaPdeyXmVwCWj2KcUjIwYA==", "dependencies": { "@noble/curves": "^1.4.0", "@noble/hashes": "^1.3.1", @@ -1093,73 +1112,14 @@ "debug": "^4.3.4", "light-bolt11-decoder": "^3.0.0", "node-fetch": "^3.3.1", - "nostr-tools": "^1.15.0", + "nostr-tools": "^2.7.1", "tseep": "^1.1.1", "typescript-lru-cache": "^2.0.0", "utf8-buffer": "^1.0.0", "websocket-polyfill": "^0.0.3" - } - }, - "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/ciphers": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz", - "integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/hashes": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", - "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + }, "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@nostr-dev-kit/ndk/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/@nostr-dev-kit/ndk/node_modules/nostr-tools": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.17.0.tgz", - "integrity": "sha512-LZmR8GEWKZeElbFV5Xte75dOeE9EFUW/QLI1Ncn3JKn0kFddDKEfBbFN8Mu4TMs+L4HR/WTPha2l+PPuRnJcMw==", - "dependencies": { - "@noble/ciphers": "0.2.0", - "@noble/curves": "1.1.0", - "@noble/hashes": "1.3.1", - "@scure/base": "1.1.1", - "@scure/bip32": "1.3.1", - "@scure/bip39": "1.2.1" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/curves": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", - "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", - "dependencies": { - "@noble/hashes": "1.3.1" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=16" } }, "node_modules/@reduxjs/toolkit": { @@ -1543,6 +1503,14 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chrome": { + "version": "0.0.74", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.74.tgz", + "integrity": "sha512-hzosS5CkQcIKCgxcsV2AzbJ36KNxG/Db2YEN/erEu7Boprg+KpMDLBQqKFmSo+JkQMGqRcicUyqCowJpuT+C6A==", + "dependencies": { + "@types/filesystem": "*" + } + }, "node_modules/@types/dompurify": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", @@ -1564,6 +1532,19 @@ "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", "dev": true }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==" + }, "node_modules/@types/lodash": { "version": "4.17.7", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", @@ -1961,12 +1942,51 @@ "node": ">=8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.3.tgz", + "integrity": "sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2032,6 +2052,29 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/bufferutil": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", @@ -2181,6 +2224,17 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2321,6 +2375,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -2947,6 +3009,38 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -3189,6 +3283,25 @@ "node": ">= 0.4" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -3540,6 +3653,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -3795,6 +3927,14 @@ "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", "optional": true }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-is": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", @@ -4006,6 +4146,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4015,6 +4170,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-3.1.0.tgz", + "integrity": "sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4072,6 +4235,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-countdown": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/react-countdown/-/react-countdown-2.3.5.tgz", + "integrity": "sha512-K26ENYEesMfPxhRRtm1r+Pf70SErrvW3g4CArLi/x6MPFjgfDFYePT4UghEj8p2nI0cqVV7/JjDgjyr//U60Og==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">= 15", + "react-dom": ">= 15" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -4084,6 +4259,11 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/react-quill": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz", @@ -4786,6 +4966,14 @@ "node": ">= 8" } }, + "node_modules/webln": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/webln/-/webln-0.3.2.tgz", + "integrity": "sha512-YYT83aOCLup2AmqvJdKtdeBTaZpjC6/JDMe8o6x1kbTYWwiwrtWHyO//PAsPixF3jwFsAkj5DmiceB6w/QSe7Q==", + "dependencies": { + "@types/chrome": "^0.0.74" + } + }, "node_modules/websocket": { "version": "1.0.35", "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", diff --git a/package.json b/package.json index 2f2f2b6..5b424a2 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,12 @@ "preview": "vite preview" }, "dependencies": { - "@nostr-dev-kit/ndk": "2.8.2", + "@getalby/lightning-tools": "5.0.3", + "@nostr-dev-kit/ndk": "2.10.0", "@reduxjs/toolkit": "2.2.6", + "axios": "1.7.3", + "bech32": "2.0.0", + "buffer": "6.0.3", "date-fns": "3.6.0", "dompurify": "3.1.6", "file-saver": "2.0.5", @@ -19,14 +23,17 @@ "nostr-login": "1.5.2", "nostr-tools": "2.7.1", "papaparse": "5.4.1", + "qrcode.react": "3.1.0", "react": "^18.3.1", + "react-countdown": "2.3.5", "react-dom": "^18.3.1", "react-quill": "2.0.0", "react-redux": "9.1.2", "react-router-dom": "^6.24.1", "react-toastify": "10.0.5", "react-window": "1.8.10", - "uuid": "10.0.0" + "uuid": "10.0.0", + "webln": "0.3.2" }, "devDependencies": { "@types/dompurify": "3.0.5", diff --git a/src/controllers/index.ts b/src/controllers/index.ts index a8bfab8..b7b89e4 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,2 +1,3 @@ export * from './metadata' export * from './relay' +export * from './zap' diff --git a/src/controllers/metadata.ts b/src/controllers/metadata.ts index ad8609d..f40cafe 100644 --- a/src/controllers/metadata.ts +++ b/src/controllers/metadata.ts @@ -1,8 +1,8 @@ -import NDK, { NDKList, NDKRelayList, NDKUser } from '@nostr-dev-kit/ndk' -import { UserProfile } from '../types/user' -import { hexToNpub, log, LogType, npubToHex } from '../utils' +import NDK, { getRelayListForUser, NDKList, NDKUser } from '@nostr-dev-kit/ndk' import { kinds } from 'nostr-tools' import { MuteLists } from '../types' +import { UserProfile } from '../types/user' +import { hexToNpub, log, LogType, npubToHex } from '../utils' /** * Singleton class to manage metadata operations using NDK. @@ -10,6 +10,7 @@ import { MuteLists } from '../types' export class MetadataController { private static instance: MetadataController private ndk: NDK + private usersMetadata = new Map() public adminNpubs: string[] public adminRelays = new Set() @@ -18,7 +19,8 @@ export class MetadataController { explicitRelayUrls: [ 'wss://user.kindpag.es', 'wss://purplepag.es', - 'wss://relay.damus.io/' + 'wss://relay.damus.io/', + import.meta.env.VITE_APP_RELAY ] }) this.ndk.connect() @@ -31,7 +33,7 @@ export class MetadataController { const hexKey = npubToHex(npub) if (!hexKey) return null - return NDKRelayList.forUser(hexKey, this.ndk) + return getRelayListForUser(hexKey, this.ndk) .then((ndkRelayList) => { if (ndkRelayList) { ndkRelayList.writeRelayUrls.forEach((url) => @@ -74,14 +76,34 @@ export class MetadataController { */ public findMetadata = async (pubkey: string): Promise => { const npub = hexToNpub(pubkey) + + const cachedMetadata = this.usersMetadata.get(npub) + if (cachedMetadata) { + return cachedMetadata + } + const user = new NDKUser({ npub }) user.ndk = this.ndk - return await user.fetchProfile() + const userProfile = await user.fetchProfile() + if (userProfile) { + this.usersMetadata.set(npub, userProfile) + } + + return userProfile + } + + /** + * Finds metadata for admin user. + * + * @returns A promise that resolves to the metadata event. + */ + public findAdminMetadata = async (): Promise => { + return this.findMetadata(this.adminNpubs[0]) } public findWriteRelays = async (hexKey: string) => { - const ndkRelayList = await NDKRelayList.forUser(hexKey, this.ndk) + const ndkRelayList = await getRelayListForUser(hexKey, this.ndk) if (!ndkRelayList) { throw new Error(`Couldn't found user's relay list`) diff --git a/src/controllers/relay.ts b/src/controllers/relay.ts index 18b42b8..fed0cbf 100644 --- a/src/controllers/relay.ts +++ b/src/controllers/relay.ts @@ -12,7 +12,19 @@ export class RelayController { private constructor() {} - private connectRelay = async (relayUrl: string) => { + /** + * Provides the singleton instance of RelayController. + * + * @returns The singleton instance of RelayController. + */ + public static getInstance(): RelayController { + if (!RelayController.instance) { + RelayController.instance = new RelayController() + } + return RelayController.instance + } + + public connectRelay = async (relayUrl: string) => { const relay = this.connectedRelays.find( (relay) => normalizeWebSocketURL(relay.url) === normalizeWebSocketURL(relayUrl) @@ -39,18 +51,6 @@ export class RelayController { }) } - /** - * Provides the singleton instance of RelayController. - * - * @returns The singleton instance of RelayController. - */ - public static getInstance(): RelayController { - if (!RelayController.instance) { - RelayController.instance = new RelayController() - } - return RelayController.instance - } - /** * Publishes an event to multiple relays. * diff --git a/src/controllers/zap.ts b/src/controllers/zap.ts new file mode 100644 index 0000000..3efc698 --- /dev/null +++ b/src/controllers/zap.ts @@ -0,0 +1,349 @@ +import { Invoice } from '@getalby/lightning-tools' +import axios, { AxiosInstance } from 'axios' +import { bech32 } from 'bech32' +import { Buffer } from 'buffer' +import { Filter, kinds } from 'nostr-tools' +import { requestProvider, SendPaymentResponse, WebLNProvider } from 'webln' +import { + isLnurlResponse, + LnurlResponse, + PaymentRequest, + SignedEvent, + ZapReceipt, + ZapRequest +} from '../types' +import { log, LogType, npubToHex } from '../utils' +import { RelayController } from './relay' + +/** + * Singleton class to manage zap related operations. + */ +export class ZapController { + private static instance: ZapController + private webln: WebLNProvider | null = null + private httpClient: AxiosInstance + private appRelay = import.meta.env.VITE_APP_RELAY + + private constructor() { + this.httpClient = axios.create() + } + + /** + * @returns The singleton instance of ZapController. + */ + public static getInstance(): ZapController { + if (!ZapController.instance) { + ZapController.instance = new ZapController() + } + return ZapController.instance + } + + /** + * Generates ZapRequest and payment request string. More info can be found at + * https://github.com/nostr-protocol/nips/blob/master/57.md. + * @param lud16 - LUD-16 of the recipient. + * @param amount - payment amount (will be multiplied by 1000 to represent sats). + * @param recipientPubKey - pubKey of the recipient. + * @param senderPubkey - pubKey of of the sender. + * @param content - optional content (comment). + * @param eventId - event id, if zapping an event. + * @returns - promise that resolves into object containing zap request and payment + * request string + */ + async getLightningPaymentRequest( + lud16: string, + amount: number, + recipientPubKey: string, + senderPubkey: string, + content?: string, + eventId?: string + ) { + // Check if amount is greater than 0 + if (amount <= 0) throw 'Amount should be > 0.' + + // convert to mili satoshis + amount *= 1000 + + // decode lud16 into lnurl + const lnurl = this.decodeLud16(lud16) + + // get receiver lightning details from lnurl pay endpoint + const lnurlResponse = await this.getLnurlResponse(lnurl) + + const { minSendable, maxSendable, callback } = lnurlResponse + + // check if the amount is within minSendable and maxSendable values + if (amount < minSendable || amount > maxSendable) { + throw `Amount '${amount}' is not within minSendable and maxSendable values '${minSendable}-${maxSendable}'.` + } + + // encode lnurl into bech32 using lnurl prefix + const lnurlBech32 = bech32.encode( + 'lnurl', + bech32.toWords(Buffer.from(lnurl, 'utf8')) + ) + + // generate zap request + const zapRequest = await this.createZapRequest( + amount, + content, + lnurlBech32, + recipientPubKey, + senderPubkey, + eventId + ) + + if (!window.nostr?.signEvent) { + log( + true, + LogType.Error, + 'Failed to sign the zap request!', + 'window.nostr.signEvent is not defined' + ) + throw 'Failed to sign zap Request!' + } + + // Sign zap request. This is validated by the lightning provider prior to sending the invoice(NIP-57). + const signedEvent = await window.nostr + .signEvent(zapRequest) + .then((event) => event as SignedEvent) + .catch((err) => { + log(true, LogType.Error, 'Failed to sign the zap request!', err) + throw 'Failed to sign the zap request!' + }) + + // Kind 9734 event must be signed and sent + // in order to receive the invoice from the provider. + // Encode stringified signed zap request. + const encodedEvent = encodeURI(JSON.stringify(signedEvent)) + + // send zap request as GET request to callback url received from the lnurl pay endpoint + const { data } = await this.httpClient.get( + `${callback}?amount=${amount}&nostr=${encodedEvent}&lnurl=${lnurlBech32}` + ) + + // data object of the response should contain payment request + if (data && data.pr) { + return Promise.resolve({ ...signedEvent, pr: data.pr }) + } + + throw 'lnurl callback did not return payment request.' + } + + /** + * Polls zap receipt. + * @param paymentRequest - payment request object containing zap request and + * payment request string. + * @param pollingTimeout - polling timeout (secs), by default equals to 6min. + * @returns - promise that resolves into zap receipt. + */ + async pollZapReceipt( + paymentRequest: PaymentRequest, + pollingTimeout?: number + ) { + const { pr, ...zapRequest } = paymentRequest + const { created_at } = zapRequest + + // stringify zap request + const zapRequestStringified = JSON.stringify(zapRequest) + + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + // clear polling timeout + const cleanup = () => { + clearTimeout(timeout) + + sub.close() + } + + // Polling timeout + const timeout = setTimeout( + () => { + cleanup() + + reject('Zap receipt was not received.') + }, + pollingTimeout || 6 * 60 * 1000 // 6 minutes + ) + + const relay = await RelayController.getInstance().connectRelay( + this.appRelay + ) + + if (!relay) { + return reject('Polling Zap Receipt: Could not connect to app relay!') + } + + // filter relay for event of kind 9735 + const filter: Filter = { + kinds: [kinds.Zap], + since: created_at + } + + const sub = relay.subscribe([filter], { + // Handle incoming events + onevent: async (event) => { + // get description tag of the event + const description = event.tags.filter( + (tag) => tag[0] === 'description' + )[0] + + // compare description tag of the event with stringified zap request + if (description[1] === zapRequestStringified) { + // validate zap receipt + if (await this.validateZapReceipt(pr, event as ZapReceipt)) { + cleanup() + + resolve(event as ZapReceipt) + } + } + } + }) + }) + } + + async isWeblnProviderExists(): Promise { + await this.requestWeblnProvider() + return !!this.webln + } + + async sendPayment(invoice: string): Promise { + if (this.webln) { + return await this.webln!.sendPayment(invoice).catch((err) => { + throw new Error(`Error while sending payment. Error: ${err.message}`) + }) + } + + throw 'Webln is not defined!' + } + + /** + * Decodes LUD-16 into lnurl. + * @param lud16 - LUD-16 that looks like @. + * @returns - lnurl that looks like 'http:///.well-known/lnurlp/'. + */ + private decodeLud16(lud16: string) { + const username = lud16.split('@')[0] + const domain = lud16.split('@')[1] + + if (!domain || !username) throw `Provided lud16 '${lud16}' is not valid.` + + return `https://${domain}/.well-known/lnurlp/${username}` + } + + /** + * Fetches and validates response from lnurl pay endpoint. + * + * @param lnurl - lnurl pay endpoint. + * @returns response object that conforms to LnurlResponse interface. + */ + private async getLnurlResponse(lnurl: string): Promise { + // get request from lnurl pay endpoint + const { data: lnurlResponse } = await this.httpClient.get(lnurl) + + // validate lnurl response + this.validateLnurlResponse(lnurlResponse) + + // return callback URL + return Promise.resolve(lnurlResponse) + } + + /** + * Checks if response conforms to LnurlResponse interface and if 'allowsNostr' + * and 'nostrPubkey' supported. + * + * @param response - response received from lnurl pay endpoint. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private validateLnurlResponse(response: any) { + if (!isLnurlResponse(response)) { + throw 'Provided response is not LnurlResponse.' + } + + if (!response.allowsNostr) throw `'allowsNostr' is not supported.` + if (!response.nostrPubkey) throw `'nostrPubkey' is not supported.` + } + + /** + * Constructs zap request object. + * @param amount - request amount (sats). + * @param content - comment. + * @param lnurl - lnurl pay endpoint. + * @param recipientPubKey - pubKey of the recipient. + * @param senderPubkey - pubKey of of the sender. + * @param eventId - event id, if zapping an event. + * @returns zap request + */ + private async createZapRequest( + amount: number, + content = '', + lnurl: string, + recipientPubKey: string, + senderPubkey: string, + eventId?: string + ): Promise { + const recipientHexKey = npubToHex(recipientPubKey) + + if (!recipientHexKey) throw 'Invalid recipient pubKey.' + + const zapRequest: ZapRequest = { + kind: kinds.ZapRequest, + content, + tags: [ + ['relays', `${this.appRelay}`], + ['amount', `${amount}`], + ['lnurl', lnurl], + ['p', recipientHexKey] + ], + pubkey: senderPubkey, + created_at: Math.round(Date.now() / 1000) + } + + // add event id to the tags, if zapping an event. + if (eventId) zapRequest.tags.push(['e', eventId]) + + return zapRequest + } + + /** + * Validates zap receipt preimage and payment request string + * @param paymentRequest - payment request string + * @param event - zap receipt. + * @returns - boolean indicating if preimage in zap receipt is valid + */ + private async validateZapReceipt(paymentRequest: string, event: ZapReceipt) { + const invoice = new Invoice({ pr: paymentRequest }) + + return invoice.validatePreimage(this.getPreimageFromZapReceipt(event)) + } + + /** + * Gets preimage from zap receipt. + * @param event - zap receipt (9735 kind). + * @returns - preimage string. + */ + private getPreimageFromZapReceipt(event: ZapReceipt) { + // filter tags by 1st item + const preimageTag = event.tags.filter((tag) => tag[0] === 'preimage')[0] + + // throw an error if 'preimage' tag is not present + if (!preimageTag || preimageTag.length != 2) { + throw `'preimage' tag is not present.` + } + + const preimage = preimageTag[1] + + // throw an error if 'preimage' value is not present + if (!preimage) throw `'preimage' tag is not valid.` + + return preimage + } + + private async requestWeblnProvider() { + if (!this.webln) + this.webln = await requestProvider().catch((err) => { + console.log('err in requesting WebLNProvider :>> ', err.message) + return null + }) + } +} diff --git a/src/layout/header.tsx b/src/layout/header.tsx index 84a65e4..2a4042e 100644 --- a/src/layout/header.tsx +++ b/src/layout/header.tsx @@ -1,17 +1,35 @@ -import navStyles from '../styles/nav.module.scss' -import mainStyles from '../styles//main.module.scss' -import { Banner } from '../components/Banner' -import { Link } from 'react-router-dom' -import { appRoutes } from '../routes' import { init as initNostrLogin, launch as launchNostrLoginDialog } from 'nostr-login' -import { useEffect } from 'react' -import { useAppDispatch, useAppSelector } from '../hooks' +import React, { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState +} from 'react' +import { Link } from 'react-router-dom' +import { Banner } from '../components/Banner' +import { MetadataController, ZapController } from '../controllers' +import { useAppDispatch, useAppSelector, useDidMount } from '../hooks' +import { appRoutes } from '../routes' import { setIsAuth, setUser } from '../store/reducers/user' -import { MetadataController } from '../controllers' -import { npubToHex } from '../utils' +import mainStyles from '../styles//main.module.scss' +import navStyles from '../styles/nav.module.scss' +import '../styles/popup.css' +import { + copyTextToClipboard, + formatNumber, + npubToHex, + unformatNumber +} from '../utils' +import { toast } from 'react-toastify' +import { PaymentRequest } from '../types' +import { LoadingSpinner } from '../components/LoadingSpinner' +import { QRCodeSVG } from 'qrcode.react' +import Countdown, { CountdownRenderProps } from 'react-countdown' export const Header = () => { const dispatch = useAppDispatch() @@ -28,7 +46,12 @@ export const Header = () => { dispatch(setUser(null)) } else { dispatch(setIsAuth(true)) - dispatch(setUser({ npub })) + dispatch( + setUser({ + npub, + pubkey: npubToHex(npub)! + }) + ) MetadataController.getInstance().then((metadataController) => { metadataController.findMetadata(npub).then((userProfile) => { if (userProfile) { @@ -72,20 +95,7 @@ export const Header = () => {
) } + +const TipButtonWithDialog = React.memo(() => { + const [isOpen, setIsOpen] = useState(false) + + const [isLoading, setIsLoading] = useState(false) + const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') + + const [amount, setAmount] = useState(0) + const [message, setMessage] = useState('') + + const [paymentRequest, setPaymentRequest] = useState() + + const userState = useAppSelector((state) => state.user) + + const handleClose = useCallback(() => { + setPaymentRequest(undefined) + setIsLoading(false) + setIsOpen(false) + }, []) + + const handleQRExpiry = useCallback(() => { + setPaymentRequest(undefined) + }, []) + + const handleAmountChange = (event: React.ChangeEvent) => { + const unformattedValue = unformatNumber(event.target.value) + setAmount(unformattedValue) + } + + const generatePaymentRequest = + useCallback(async (): Promise => { + let userHexKey: string + + setIsLoading(true) + setLoadingSpinnerDesc('Getting user pubkey') + + if (userState.isAuth && userState.user?.pubkey) { + userHexKey = userState.user.pubkey as string + } else { + userHexKey = (await window.nostr?.getPublicKey()) as string + } + + if (!userHexKey) { + setIsLoading(false) + toast.error('Could not get pubkey') + return null + } + + setLoadingSpinnerDesc('Getting admin metadata') + const metadataController = await MetadataController.getInstance() + + const adminMetadata = await metadataController.findAdminMetadata() + + if (!adminMetadata?.lud16) { + setIsLoading(false) + toast.error('Lighting address (lud16) is missing in admin metadata!') + return null + } + + if (!adminMetadata?.pubkey) { + setIsLoading(false) + toast.error('pubkey is missing in admin metadata!') + return null + } + + const zapController = ZapController.getInstance() + + setLoadingSpinnerDesc('Creating zap request') + return await zapController + .getLightningPaymentRequest( + adminMetadata.lud16, + amount, + adminMetadata.pubkey as string, + userHexKey, + message + ) + .catch((err) => { + toast.error(err.message || err) + return null + }) + .finally(() => { + setIsLoading(false) + }) + }, [amount, message, userState]) + + const handleSend = useCallback(async () => { + const pr = await generatePaymentRequest() + + if (!pr) return + + setIsLoading(true) + setLoadingSpinnerDesc('Sending payment!') + + const zapController = ZapController.getInstance() + + if (await zapController.isWeblnProviderExists()) { + await zapController + .sendPayment(pr.pr) + .then(() => { + toast.success(`Successfully sent ${amount} sats!`) + handleClose() + }) + .catch((err) => { + toast.error(err.message || err) + }) + } else { + toast.warn('Webln is not present. Use QR code to send zap.') + setPaymentRequest(pr) + } + + setIsLoading(false) + }, [amount, handleClose, generatePaymentRequest]) + + const handleGenerateQRCode = async () => { + const pr = await generatePaymentRequest() + + if (!pr) return + + setPaymentRequest(pr) + } + + return ( + <> + setIsOpen(true)} + > + + + + Tip + + {isOpen && ( +
+
+
+
+
+
+

Tip/Zap DEG Mods

+
+
+ + + +
+
+
+
+
+
+

+ If you want the development and maintenance of DEG + Mods to stop, then a tip helps continue it. +

+ + +
+
+ + + + +
+
+
+ + setMessage(e.target.value)} + /> +
+
+ + +
+ {paymentRequest && ( + + )} +
+
+
+
+
+
+ )} + {isLoading && } + + ) +}) + +type PresetAmountProps = { + label: string + value: number + setAmount: Dispatch> +} + +const PresetAmount = React.memo( + ({ label, value, setAmount }: PresetAmountProps) => { + return ( + + ) + } +) + +type ZapQRProps = { + paymentRequest: PaymentRequest + handleClose: () => void + handleQRExpiry: () => void +} + +const ZapQR = React.memo( + ({ paymentRequest, handleClose, handleQRExpiry }: ZapQRProps) => { + useDidMount(() => { + ZapController.getInstance() + .pollZapReceipt(paymentRequest) + .then(() => { + toast.success(`Successfully sent sats!`) + }) + .catch((err) => { + toast.error(err.message || err) + }) + .finally(() => { + handleClose() + }) + }) + + const onQrCodeClicked = async () => { + if (!paymentRequest) return + + const zapController = ZapController.getInstance() + + if (await zapController.isWeblnProviderExists()) { + zapController.sendPayment(paymentRequest.pr) + } else { + console.warn('Webln provider not present') + + const href = `lightning:${paymentRequest.pr}` + const a = document.createElement('a') + a.href = href + a.click() + } + } + + return ( +
+ + + +
+ ) + } +) + +const MAX_POLLING_TIME = 2 * 60 * 1000 // 2 minutes in milliseconds + +const renderer = ({ minutes, seconds }: CountdownRenderProps) => ( + + {minutes}:{seconds} + +) + +type TimerProps = { + onTimerExpired: () => void +} + +const Timer = React.memo(({ onTimerExpired }: TimerProps) => { + const expiryTime = useMemo(() => { + return Date.now() + MAX_POLLING_TIME + }, []) + + return ( +
+ + +
+ ) +}) diff --git a/src/styles/popup.css b/src/styles/popup.css index 2dbb62e..0741693 100644 --- a/src/styles/popup.css +++ b/src/styles/popup.css @@ -5,7 +5,7 @@ bottom: 0; left: 0; z-index: 1000; - background: rgba(0,0,0,0.5); + background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(5px); display: flex; flex-direction: column; @@ -19,7 +19,7 @@ width: 100%; max-width: 1000px; border-radius: 15px; - box-shadow: 0 0 16px 0 rgba(0,0,0,0.5); + box-shadow: 0 0 16px 0 rgba(0, 0, 0, 0.5); background: #232323; } @@ -42,7 +42,7 @@ justify-content: end; align-items: center; padding: 15px 25px 10px 25px; - border-bottom: solid 1px rgba(255,255,255,0.05); + border-bottom: solid 1px rgba(255, 255, 255, 0.05); } .popUpMainCardBottom { @@ -59,6 +59,17 @@ .popUpMainCardBottomQR { width: 100%; max-width: 250px; + cursor: pointer; +} + +.popUpMainCardBottomLnurl { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: inherit; + display: inline; + cursor: pointer; + color: rgba(255, 255, 255, 0.5); } .popUpMainCardTopClose { @@ -69,25 +80,25 @@ flex-direction: column; justify-content: center; align-items: end; - color: rgba(255,255,255,0.25); + color: rgba(255, 255, 255, 0.25); padding: 10px; border-radius: 10px; font-size: 20px; position: relative; cursor: pointer; - border: solid 1px rgba(255,255,255,0); + border: solid 1px rgba(255, 255, 255, 0); } .popUpMainCardTopClose:hover { transition: ease 0.4s; - color: rgba(255,255,255,0.75); - box-shadow: 0 0 8px 0 rgb(0,0,0,0.1); - border: solid 1px rgba(255,255,255,0.1); + color: rgba(255, 255, 255, 0.75); + box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1); + border: solid 1px rgba(255, 255, 255, 0.1); } .popUpMainCardTopInfo { width: 100%; - color: rgba(255,255,255,0.75); + color: rgba(255, 255, 255, 0.75); } .popUpMainCardTopClose:hover::before { @@ -122,7 +133,7 @@ grid-gap: 25px; display: flex; flex-direction: column; - border: solid 1px rgba(255,255,255,0.1); + border: solid 1px rgba(255, 255, 255, 0.1); border-radius: 10px; } @@ -153,13 +164,13 @@ .btnMain.pUMCB_ZapsInsideElementBtn { width: 100%; - background: rgba(255,255,255,0.05); + background: rgba(255, 255, 255, 0.05); } .btnMain.pUMCB_ZapsInsideElementBtn:hover { transition: ease 0.4s; color: yellow; - background: rgba(255,255,255,0.1); + background: rgba(255, 255, 255, 0.1); } .pUMCB_ZapsInsideAmount { @@ -185,7 +196,7 @@ justify-content: center; align-items: center; grid-gap: 10px; - color: rgba(255,255,255,0.5); + color: rgba(255, 255, 255, 0.5); border-radius: 10px; cursor: pointer; font-weight: bold; @@ -195,7 +206,7 @@ .btnMain.pUMCB_ZapsInsideAmountOptionsBtn:hover { transition: ease 0.4s; - background: rgba(255,255,255,0.1); + background: rgba(255, 255, 255, 0.1); color: yellow; } @@ -206,12 +217,12 @@ grid-gap: 25px; justify-content: center; align-items: center; - color: rgba(255,255,255,0.5); + color: rgba(255, 255, 255, 0.5); } .dividerPopupLine { height: 1px; - background: rgba(255,255,255,0.1); + background: rgba(255, 255, 255, 0.1); flex-grow: 1; } @@ -248,7 +259,7 @@ .popUpMainGalleryInsideMid { /*flex-grow: 1;*/ - background: rgba(0,0,0,0.5); + background: rgba(0, 0, 0, 0.5); /*height: 100%;*/ padding-top: 100% * (16 / 9); width: 100%; @@ -295,7 +306,7 @@ .ZapSplitUserBox { border-radius: 10px; - border: solid 1px rgba(255,255,255,0.1); + border: solid 1px rgba(255, 255, 255, 0.1); padding: 10px; display: flex; flex-direction: column; @@ -310,7 +321,7 @@ width: 100%; border-radius: 10px; text-decoration: unset; - color: rgba(255,255,255,0.65); + color: rgba(255, 255, 255, 0.65); } .ZapSplitUserBoxUserPic { @@ -335,7 +346,7 @@ } .ZapSplitUserBoxText { - color: rgba(255,255,255,0.5); + color: rgba(255, 255, 255, 0.5); } .ZapSplitUserBoxRange { @@ -346,17 +357,17 @@ .ZapSplitUserBoxRangeText { white-space: nowrap; - color: rgba(255,255,255,0.5); + color: rgba(255, 255, 255, 0.5); } .keyGenerationTable { width: 100%; border-radius: 10px; - background: rgba(255,255,255,0.05); + background: rgba(255, 255, 255, 0.05); display: flex; flex-direction: column; - border: solid 1px rgba(255,255,255,0.1); - box-shadow: 0 0 8px 0 rgb(0,0,0,0.1); + border: solid 1px rgba(255, 255, 255, 0.1); + box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1); overflow: hidden; } @@ -378,7 +389,7 @@ } .keyGenerationTableRowColText { - color: rgba(255,255,255,0.75); + color: rgba(255, 255, 255, 0.75); overflow: hidden; text-overflow: ellipsis; } @@ -388,6 +399,24 @@ } .keyGenerationTableRowCol.keyGenerationTableRowColStart { - background: rgba(255,255,255,0.1); + background: rgba(255, 255, 255, 0.1); } +.popUpMainCardBottom.popUpMainCardBottomAlt { + padding: 0; +} + +.popUpMainCardFooter { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 15px 10px; +} + +.pUMCB_ZapsInsideBtns { + display: grid; + grid-template-columns: 0.25fr 1.75fr; + width: 100%; + grid-gap: 15px; +} diff --git a/src/types/index.ts b/src/types/index.ts index ce551f7..7b04179 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,2 +1,4 @@ export * from './mod' +export * from './nostr' export * from './user' +export * from './zap' diff --git a/src/types/nostr.ts b/src/types/nostr.ts new file mode 100644 index 0000000..00e5ade --- /dev/null +++ b/src/types/nostr.ts @@ -0,0 +1,9 @@ +export interface SignedEvent { + kind: number + tags: string[][] + content: string + created_at: number + pubkey: string + id: string + sig: string +} diff --git a/src/types/zap.ts b/src/types/zap.ts new file mode 100644 index 0000000..ef9903f --- /dev/null +++ b/src/types/zap.ts @@ -0,0 +1,42 @@ +import { kinds } from 'nostr-tools' +import { SignedEvent } from '.' + +export interface LnurlResponse { + callback: string + metadata: string + minSendable: number + maxSendable: number + commentAllowed: number + payerData: { [key: string]: { [key: string]: boolean } } + tag: string + nostrPubkey: string + allowsNostr: boolean +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isLnurlResponse = (obj: any): obj is LnurlResponse => + 'callback' in obj && + 'metadata' in obj && + 'minSendable' in obj && + 'maxSendable' in obj && + 'commentAllowed' in obj && + 'payerData' in obj && + 'tag' in obj && + 'nostrPubkey' in obj && + 'allowsNostr' in obj + +export interface ZapRequest { + kind: typeof kinds.ZapRequest + content: string + tags: string[][] + pubkey: string + created_at: number +} + +export interface PaymentRequest extends SignedEvent { + pr: string +} + +export interface ZapReceipt extends SignedEvent { + kind: typeof kinds.Zap +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 96b45ee..d2ecbeb 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -67,3 +67,33 @@ export const copyTextToClipboard = async (text: string): Promise => { return false // Failed to copy } } + +/** + * Formats a number with commas as thousands separators. + * + * @param value - The number to be formatted. + * @returns A string representing the formatted number. + */ +export const formatNumber = (value: number): string => { + // Use `Math.round` to ensure the number is rounded to the nearest integer. + // `Intl.NumberFormat` creates a number format object for formatting numbers. + // The `format` method applies the format to the rounded number. + return new Intl.NumberFormat().format(Math.round(value)) +} + +/** + * Converts a formatted number string back to a numeric value. + * + * This function removes any commas used as thousand separators and parses the resulting string + * to a floating-point number. If the input is not a valid number, it returns 0. + * + * @param value - The formatted number string (e.g., "1,234,567.89"). + * @returns The numeric value represented by the string. Returns 0 if parsing fails. + */ +export const unformatNumber = (value: string): number => { + // Remove commas from the input string. The regular expression `/\,/g` matches all commas. + // Replace them with an empty string to get a plain numeric string. + // `parseFloat` converts the resulting string to a floating-point number. + // If `parseFloat` fails to parse the string, `|| 0` ensures that the function returns 0. + return parseFloat(value.replace(/,/g, '')) || 0 +}