diff --git a/index.html b/index.html index 501fda6..461cf18 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ <meta charset="UTF-8" /> <link rel="icon" type="image/png" href="/favicon.png" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <link rel="manifest" href="/app.webmanifest" /> <title>SIGit</title> </head> <body> diff --git a/package-lock.json b/package-lock.json index 6235019..4c6f81b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sigit", - "version": "0.0.0-beta", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sigit", - "version": "0.0.0-beta", + "version": "1.0.3", "hasInstallScript": true, "license": "AGPL-3.0-or-later ", "dependencies": { @@ -20,11 +20,11 @@ "@mui/lab": "5.0.0-alpha.166", "@mui/material": "5.15.11", "@noble/hashes": "^1.4.0", - "@nostr-dev-kit/ndk": "2.10.0", - "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", + "@nostr-dev-kit/ndk": "2.11.0", + "@nostr-dev-kit/ndk-cache-dexie": "2.5.9", "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", - "axios": "^1.7.4", + "axios": "^1.8.2", "crypto-hash": "3.0.0", "crypto-js": "^4.2.0", "dexie": "4.0.8", @@ -51,8 +51,7 @@ "react-toastify": "10.0.4", "redux": "5.0.1", "signature_pad": "^5.0.4", - "tseep": "1.2.1", - "use-immer": "^0.11.0" + "tseep": "1.2.1" }, "devDependencies": { "@saithodev/semantic-release-gitea": "^2.1.0", @@ -109,12 +108,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" @@ -306,17 +307,19 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -331,37 +334,28 @@ } }, "node_modules/@babel/helpers": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.9.tgz", - "integrity": "sha512-87ICKgU5t5SzOT7sBMfCOZQ2rHjRU+Pcb9BoILMYz600W6DkVRLFBPwQ18gwUVvggqXivaUakpnxWQGbpywbBQ==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", + "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.23.9", - "@babel/traverse": "^7.23.9", - "@babel/types": "^7.23.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", - "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.0" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -400,9 +394,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", - "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -411,14 +406,15 @@ } }, "node_modules/@babel/template": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.23.9.tgz", - "integrity": "sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", + "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.23.9", - "@babel/types": "^7.23.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" @@ -446,13 +442,13 @@ } }, "node_modules/@babel/types": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", - "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", + "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1698,15 +1694,13 @@ } }, "node_modules/@noble/secp256k1": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.0.0.tgz", - "integrity": "sha512-rUGBd95e2a45rlmFTqQJYEFA4/gdIARFfuTuTqLglz0PZ6AKyzyXsEZZq7UZn8hZsvaBgpCzKKBJizT2cJERXw==", - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ] + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.2.3.tgz", + "integrity": "sha512-l7r5oEQym9Us7EAigzg30/PQAvynhMt2uoYtT3t26eGDVm9Yii5mZ5jWSWmZ/oSIR2Et0xfc6DXrG0bZ787V3w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", @@ -1744,19 +1738,19 @@ } }, "node_modules/@nostr-dev-kit/ndk": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.0.tgz", - "integrity": "sha512-TqCAAo6ylORraAXrzRkCGFN2xTMiFbdER8Y8CtUT0HwOpFG/Wn+PBNeDeDmqkl/6LaPdeyXmVwCWj2KcUjIwYA==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.11.0.tgz", + "integrity": "sha512-FKIMtcVsVcquzrC+yir9lOXHCIHmQ3IKEVCMohqEB7N96HjP2qrI9s5utbjI3lkavFNF5tXg1Gp9ODEo7XCfLA==", + "license": "MIT", "dependencies": { - "@noble/curves": "^1.4.0", - "@noble/hashes": "^1.3.1", - "@noble/secp256k1": "^2.0.0", - "@scure/base": "^1.1.1", - "debug": "^4.3.4", - "light-bolt11-decoder": "^3.0.0", - "node-fetch": "^3.3.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@noble/secp256k1": "^2.1.0", + "@scure/base": "^1.1.9", + "debug": "^4.3.6", + "light-bolt11-decoder": "^3.2.0", "nostr-tools": "^2.7.1", - "tseep": "^1.1.1", + "tseep": "^1.2.2", "typescript-lru-cache": "^2.0.0", "utf8-buffer": "^1.0.0", "websocket-polyfill": "^0.0.3" @@ -1766,17 +1760,41 @@ } }, "node_modules/@nostr-dev-kit/ndk-cache-dexie": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.1.tgz", - "integrity": "sha512-tUwEy68bd9GL5JVuZIjcpdwuDEBnaXen3WJ64/GRDtbyE1RB01Y6hHC7IQC9bcQ6SC7XBGyPd+2nuTyR7+Mffg==", + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.9.tgz", + "integrity": "sha512-SZ5FjON0QPekiC7oW9Hy3JQxG0Oxxtud9LBa1q/A49JV/Qppv1x37nFHxi0XLxEbDgFTNYbaN27Zjfp2NPem2g==", + "license": "MIT", "dependencies": { - "@nostr-dev-kit/ndk": "2.10.0", - "debug": "^4.3.4", - "dexie": "^4.0.2", + "@nostr-dev-kit/ndk": "2.11.0", + "debug": "^4.3.7", + "dexie": "^4.0.8", "nostr-tools": "^2.4.0", "typescript-lru-cache": "^2.0.0" } }, + "node_modules/@nostr-dev-kit/ndk-cache-dexie/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@nostr-dev-kit/ndk-cache-dexie/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/curves": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz", @@ -1802,6 +1820,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nostr-dev-kit/ndk/node_modules/@scure/base": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.4.tgz", + "integrity": "sha512-5Yy9czTO47mqz+/J8GM6GIId4umdCk1wc1q8rKERQulIoc8VP9pzDcghv10Tl2E7R96ZUx/PhND3ESYUQX8NuQ==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools": { "version": "2.10.4", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz", @@ -1859,6 +1886,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/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/" + } + ], + "license": "MIT" + }, + "node_modules/@nostr-dev-kit/ndk/node_modules/tseep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tseep/-/tseep-1.3.1.tgz", + "integrity": "sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ==", + "license": "MIT" + }, "node_modules/@octokit/auth-token": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", @@ -1891,9 +1936,9 @@ } }, "node_modules/@octokit/endpoint": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.2.tgz", - "integrity": "sha512-XybpFv9Ms4hX5OCHMZqyODYqGTZ3H6K6Vva+M9LR7ib/xr1y1ZnlChYv9H680y77Vd/i/k+thXApeRASBQkzhA==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.3.tgz", + "integrity": "sha512-nBRBMpKPhQUxCsQQeW+rCJ/OPSMcj3g0nfHn01zGYZXuNDvvXudF/TYY6APj5THlurerpFN4a/dQAIAaM6BYhA==", "dev": true, "license": "MIT", "peer": true, @@ -1922,22 +1967,22 @@ } }, "node_modules/@octokit/openapi-types": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz", - "integrity": "sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g==", + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@octokit/plugin-paginate-rest": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.0.tgz", - "integrity": "sha512-ttpGck5AYWkwMkMazNCZMqxKqIq1fJBNxBfsFwwfyYKTf914jKkLF0POMS3YkPBwp5g1c2Y4L79gDz01GhSr1g==", + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz", + "integrity": "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@octokit/types": "^13.7.0" + "@octokit/types": "^13.10.0" }, "engines": { "node": ">= 18" @@ -1984,15 +2029,15 @@ } }, "node_modules/@octokit/request": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.0.tgz", - "integrity": "sha512-kXLfcxhC4ozCnAXy2ff+cSxpcF0A1UqxjvYMqNuPIeOAzJbVWQ+dy5G2fTylofB/gTbObT8O6JORab+5XtA1Kw==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.2.tgz", + "integrity": "sha512-dZl0ZHx6gOQGcffgm1/Sf6JfEpmh34v3Af2Uci02vzUYz6qEN6zepoRtmybWXIGXFIK8K9ylE3b+duCWqhArtg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@octokit/endpoint": "^10.0.0", - "@octokit/request-error": "^6.0.1", + "@octokit/endpoint": "^10.1.3", + "@octokit/request-error": "^6.1.7", "@octokit/types": "^13.6.2", "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" @@ -2002,9 +2047,9 @@ } }, "node_modules/@octokit/request-error": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.6.tgz", - "integrity": "sha512-pqnVKYo/at0NuOjinrgcQYpEbv4snvP3bKMRqHaD9kIsk9u1LCpb2smHZi8/qJfgeNqLo5hNW4Z7FezNdEo0xg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.7.tgz", + "integrity": "sha512-69NIppAwaauwZv6aOzb+VVLwt+0havz9GT5YplkeJv7fG7a40qpLt/yZKyiDxAhgz0EtgNdNcb96Z0u+Zyuy2g==", "dev": true, "license": "MIT", "peer": true, @@ -2016,14 +2061,14 @@ } }, "node_modules/@octokit/types": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.7.0.tgz", - "integrity": "sha512-BXfRP+3P3IN6fd4uF3SniaHKOO4UXWBfkdR3vA8mIvaoO/wLjGN5qivUtW0QRitBHHMcfC41SLhNVYIZZE+wkA==", + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@octokit/openapi-types": "^23.0.1" + "@octokit/openapi-types": "^24.2.0" } }, "node_modules/@pdf-lib/fontkit": { @@ -3814,6 +3859,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -4003,9 +4049,9 @@ } }, "node_modules/axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", + "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -4516,6 +4562,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -4973,6 +5020,7 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -4980,7 +5028,8 @@ "node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true }, "node_modules/color-support": { "version": "1.1.3", @@ -6141,14 +6190,6 @@ "node": ">=8" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "engines": { - "node": ">= 12" - } - }, "node_modules/dateformat": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", @@ -6684,9 +6725,9 @@ "dev": true }, "node_modules/elliptic": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.0.tgz", - "integrity": "sha512-dpwoQcLc/2WLQvJvLRHKZ+f9FgOdjnq11rurqwekGQygGPsYSK29OMMD2WalatiqQ+XGFDglTNixpPfI+lpaAA==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", "dev": true, "license": "MIT", "dependencies": { @@ -6899,6 +6940,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, "engines": { "node": ">=0.8.0" } @@ -7370,28 +7412,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -7570,17 +7590,6 @@ "node": ">= 6" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -8271,6 +8280,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, "engines": { "node": ">=4" } @@ -9180,9 +9190,10 @@ } }, "node_modules/light-bolt11-decoder": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.0.0.tgz", - "integrity": "sha512-AKvOigD2pmC8ktnn2TIqdJu0K0qk6ukUmTvHwF3JNkm8uWCqt18Ijn33A/a7gaRZ4PghJ59X+8+MXrzLKdBTmQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz", + "integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==", + "license": "MIT", "dependencies": { "@scure/base": "1.1.1" } @@ -10257,24 +10268,6 @@ "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "engines": { - "node": ">=10.5.0" - } - }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -10306,23 +10299,6 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/node-gyp-build": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.0.tgz", @@ -13906,7 +13882,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -16609,6 +16584,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -16877,14 +16853,6 @@ "node": ">=0.6.0" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" - } - }, "node_modules/to-readable-stream": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-2.1.0.tgz", @@ -17265,16 +17233,6 @@ "dev": true, "license": "MIT" }, - "node_modules/use-immer": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/use-immer/-/use-immer-0.11.0.tgz", - "integrity": "sha512-RNAqi3GqsWJ4bcCd4LMBgdzvPmTABam24DUaFiKfX9s3MSorNRz9RDZYJkllJoMHUxVLMDetwAuCDeyWNrp1yA==", - "license": "MIT", - "peerDependencies": { - "immer": ">=8.0.0", - "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -17347,9 +17305,9 @@ } }, "node_modules/vite": { - "version": "5.4.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", - "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", + "version": "5.4.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.16.tgz", + "integrity": "sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==", "dev": true, "license": "MIT", "dependencies": { @@ -17462,14 +17420,6 @@ "dev": true, "license": "MIT" }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "engines": { - "node": ">= 8" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index f803726..69c59f4 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,11 @@ "@mui/lab": "5.0.0-alpha.166", "@mui/material": "5.15.11", "@noble/hashes": "^1.4.0", - "@nostr-dev-kit/ndk": "2.10.0", - "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", + "@nostr-dev-kit/ndk": "2.11.0", + "@nostr-dev-kit/ndk-cache-dexie": "2.5.9", "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", - "axios": "^1.7.4", + "axios": "^1.8.2", "crypto-hash": "3.0.0", "crypto-js": "^4.2.0", "dexie": "4.0.8", @@ -62,8 +62,7 @@ "react-toastify": "10.0.4", "redux": "5.0.1", "signature_pad": "^5.0.4", - "tseep": "1.2.1", - "use-immer": "^0.11.0" + "tseep": "1.2.1" }, "devDependencies": { "@saithodev/semantic-release-gitea": "^2.1.0", diff --git a/public/app.webmanifest b/public/app.webmanifest new file mode 100644 index 0000000..c0b073f --- /dev/null +++ b/public/app.webmanifest @@ -0,0 +1,58 @@ +{ + "short_name": "SIGit", + "name": "SIGit", + "description": "A decentralised document signing tool", + "icons": [ + { + "src": "favicon-512x512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "favicon-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "favicon-256x256.png", + "sizes": "256x256", + "type": "image/png" + }, + { + "src": "favicon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "favicon-144x144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "favicon-128x128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "favicon-96x96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "favicon-72x72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "favicon-64x64.png", + "sizes": "64x64", + "type": "image/png" + } + ], + "start_url": "/", + "display_override": ["minimal-ui", "standalone"], + "display": "minimal-ui", + "orientation": "any", + "theme_color": "#7d54a3", + "background_color": "#ffffff" +} diff --git a/public/favicon-128x128.png b/public/favicon-128x128.png new file mode 100644 index 0000000..ada1e35 Binary files /dev/null and b/public/favicon-128x128.png differ diff --git a/public/favicon-144x144.png b/public/favicon-144x144.png new file mode 100644 index 0000000..d226bc3 Binary files /dev/null and b/public/favicon-144x144.png differ diff --git a/public/favicon-192x192.png b/public/favicon-192x192.png new file mode 100644 index 0000000..d5ce791 Binary files /dev/null and b/public/favicon-192x192.png differ diff --git a/public/favicon-256x256.png b/public/favicon-256x256.png new file mode 100644 index 0000000..b6a340f Binary files /dev/null and b/public/favicon-256x256.png differ diff --git a/public/favicon-384x384.png b/public/favicon-384x384.png new file mode 100644 index 0000000..cc9872e Binary files /dev/null and b/public/favicon-384x384.png differ diff --git a/public/favicon-512x512.png b/public/favicon-512x512.png new file mode 100644 index 0000000..fe48fe3 Binary files /dev/null and b/public/favicon-512x512.png differ diff --git a/public/favicon-64x64.png b/public/favicon-64x64.png new file mode 100644 index 0000000..d59e016 Binary files /dev/null and b/public/favicon-64x64.png differ diff --git a/public/favicon-72x72.png b/public/favicon-72x72.png new file mode 100644 index 0000000..168bb20 Binary files /dev/null and b/public/favicon-72x72.png differ diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png new file mode 100644 index 0000000..5267e60 Binary files /dev/null and b/public/favicon-96x96.png differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..144306d --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 282.61 282.61"> + <defs> + <style> + .cls-1 { + fill: #47b17d; + } + + .cls-2 { + fill: #4c82a3; + } + + .cls-3 { + fill: #7d54a3; + } + </style> + </defs> + <g id="Layer_1-2" data-name="Layer 1" transform="translate(0, 13.775)"> + <g> + <path class="cls-2" d="M181.53,115.06h0c-9.4-36.67-56.77-24.79-121.09-12.57C-3.54,114.64-25.35,19.85,37.72,3.62,46.91,1.26,56.55,0,66.47,0c63.55,0,115.06,51.51,115.06,115.06Z"/> + <path class="cls-1" d="M100,140h0c9.4,36.67,56.77,24.79,121.09,12.57,63.98-12.16,85.79,82.64,22.72,98.86-9.19,2.36-18.83,3.62-28.76,3.62-63.55,0-115.06-51.51-115.06-115.06Z"/> + <circle class="cls-3" cx="140.77" cy="127.53" r="24.88"/> + </g> + </g> +</svg> \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 3829ba6..d10dc0d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,8 +4,6 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { useAppSelector, useAuth } from './hooks' import { MainLayout } from './layouts/Main' - -import { appPrivateRoutes, appPublicRoutes } from './routes' import { privateRoutes, publicRoutes, @@ -16,7 +14,7 @@ import './App.scss' const App = () => { const { checkSession } = useAuth() - const authState = useAppSelector((state) => state.auth) + const isLoggedIn = useAppSelector((state) => state.auth?.loggedIn) useEffect(() => { if (window.location.hostname === '0.0.0.0') { @@ -29,19 +27,9 @@ const App = () => { checkSession() }, [checkSession]) - const handleRootRedirect = () => { - if (authState.loggedIn) return appPrivateRoutes.homePage - - const callbackPathEncoded = btoa( - window.location.href.split(`${window.location.origin}/#`)[1] - ) - - return `${appPublicRoutes.landingPage}?callbackPath=${callbackPathEncoded}` - } - // Hide route only if loggedIn and r.hiddenWhenLoggedIn are both true const publicRoutesList = recursiveRouteRenderer(publicRoutes, (r) => { - return !authState.loggedIn || !r.hiddenWhenLoggedIn + return !isLoggedIn || !r.hiddenWhenLoggedIn }) const privateRouteList = recursiveRouteRenderer(privateRoutes) @@ -49,9 +37,9 @@ const App = () => { return ( <Routes> <Route element={<MainLayout />}> - {authState?.loggedIn && privateRouteList} {publicRoutesList} - <Route path="*" element={<Navigate to={handleRootRedirect()} />} /> + {privateRouteList} + <Route path="*" element={<Navigate to={'/'} />} /> </Route> </Routes> ) diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index 68b04dd..e1c2220 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -181,7 +181,7 @@ export const AppBar = () => { onClick={() => { setAnchorElUser(null) - navigate(appPrivateRoutes.settings) + navigate(appPrivateRoutes.profileSettings) }} sx={{ justifyContent: 'center' diff --git a/src/components/DisplaySigit/LocalDraftSigit.tsx b/src/components/DisplaySigit/LocalDraftSigit.tsx new file mode 100644 index 0000000..f59da16 --- /dev/null +++ b/src/components/DisplaySigit/LocalDraftSigit.tsx @@ -0,0 +1,117 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { Tooltip, Button, Divider } from '@mui/material' +import { + faCalendar, + faFile, + faFileCircleExclamation, + faPen, + faTrash +} from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { SigitDraft, UserRole } from '../../types' +import { appPrivateRoutes } from '../../routes' +import { + formatTimestamp, + getSigitDraft, + npubToHex, + SigitStatus, + SignStatus +} from '../../utils' +import { DisplaySigner } from '../DisplaySigner' +import { UserAvatarGroup } from '../UserAvatarGroup' +import { getExtensionIconLabel } from '../getExtensionIconLabel' +import { useAppSelector, useDidMount } from '../../hooks' +import styles from './style.module.scss' + +interface LocalDraftSigitProps { + handleDraftDelete: () => void +} +export const LocalDraftSigit = ({ + handleDraftDelete +}: LocalDraftSigitProps) => { + const [draft, setDraft] = useState<SigitDraft>() + useDidMount(async () => { + // Check if draft exists and add link to direct + const draft = await getSigitDraft() + if (draft) { + setDraft(draft) + } + }) + const submittedBy = useAppSelector((state) => state.auth.usersPubkey) + + if (!draft) return null + + const extensions = draft.files.map((f) => f.extension) + const isSame = extensions.every((e) => extensions[0] === e) + + return ( + <div className={styles.itemWrapper}> + <Link className={styles.insetLink} to={appPrivateRoutes.create}></Link> + <p className={`line-clamp-2 ${styles.title}`}>{draft.title}</p> + <div className={styles.users}> + {submittedBy && ( + <DisplaySigner status={SignStatus.Pending} pubkey={submittedBy} /> + )} + {submittedBy && draft.users.length ? ( + <Divider orientation="vertical" flexItem /> + ) : null} + <UserAvatarGroup max={7}> + {draft.users.map((user) => { + const pubkey = npubToHex(user.pubkey)! + return ( + <DisplaySigner + key={pubkey} + status={ + user.role === UserRole.signer + ? SignStatus.Pending + : SignStatus.Viewer + } + pubkey={pubkey} + /> + ) + })} + </UserAvatarGroup> + </div> + <div className={`${styles.details} ${styles.iconLabel}`}> + <FontAwesomeIcon icon={faCalendar} /> + {formatTimestamp(draft.lastUpdated)} + </div> + <div className={`${styles.details} ${styles.status}`}> + <span className={styles.iconLabel}> + <FontAwesomeIcon icon={faPen} /> {SigitStatus.LocalDraft} + </span> + {extensions.length > 0 ? ( + <span className={styles.iconLabel}> + {!isSame ? ( + <> + <FontAwesomeIcon icon={faFile} /> Multiple File Types + </> + ) : ( + getExtensionIconLabel(extensions[0]) + )} + </span> + ) : ( + <> + <FontAwesomeIcon icon={faFileCircleExclamation} /> — + </> + )} + </div> + <div className={styles.itemActions}> + <Tooltip title="Delete" arrow placement="top" disableInteractive> + <Button + onClick={handleDraftDelete} + sx={{ + color: 'var(--primary-main)', + minWidth: '34px', + padding: '10px' + }} + variant={'text'} + > + <FontAwesomeIcon icon={faTrash} /> + </Button> + </Tooltip> + </div> + </div> + ) +} diff --git a/src/components/DisplaySigit/index.tsx b/src/components/DisplaySigit/index.tsx index 5147b45..20550b3 100644 --- a/src/components/DisplaySigit/index.tsx +++ b/src/components/DisplaySigit/index.tsx @@ -112,32 +112,37 @@ export const DisplaySigit = ({ </> )} </div> - <div className={styles.itemActions}> - <Tooltip title="Duplicate" arrow placement="top" disableInteractive> - <Button - sx={{ - color: 'var(--primary-main)', - minWidth: '34px', - padding: '10px' - }} - variant={'text'} - > - <FontAwesomeIcon icon={faCopy} /> - </Button> - </Tooltip> - <Tooltip title="Archive" arrow placement="top" disableInteractive> - <Button - sx={{ - color: 'var(--primary-main)', - minWidth: '34px', - padding: '10px' - }} - variant={'text'} - > - <FontAwesomeIcon icon={faArchive} /> - </Button> - </Tooltip> - </div> + { + // TODO: enable buttons once feature is ready + false && ( + <div className={styles.itemActions}> + <Tooltip title="Duplicate" arrow placement="top" disableInteractive> + <Button + sx={{ + color: 'var(--primary-main)', + minWidth: '34px', + padding: '10px' + }} + variant={'text'} + > + <FontAwesomeIcon icon={faCopy} /> + </Button> + </Tooltip> + <Tooltip title="Archive" arrow placement="top" disableInteractive> + <Button + sx={{ + color: 'var(--primary-main)', + minWidth: '34px', + padding: '10px' + }} + variant={'text'} + > + <FontAwesomeIcon icon={faArchive} /> + </Button> + </Tooltip> + </div> + ) + } </div> ) } diff --git a/src/components/DrawPDFFields/index.tsx b/src/components/DrawPDFFields/index.tsx index 71fef1c..ee39939 100644 --- a/src/components/DrawPDFFields/index.tsx +++ b/src/components/DrawPDFFields/index.tsx @@ -18,7 +18,6 @@ import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf' import { useScale } from '../../hooks/useScale' import { AvatarIconButton } from '../UserAvatarIconButton' import { UserAvatar } from '../UserAvatar' -import { Updater } from 'use-immer' import { FileItem } from './internal/FileItem' import { FileDivider } from '../FileDivider' import { Counterpart } from './internal/Counterpart' @@ -28,6 +27,7 @@ const MINIMUM_RECT_SIZE = { height: 10 } as const import { NDKUserProfile } from '@nostr-dev-kit/ndk' +import _ from 'lodash' const DEFAULT_START_SIZE = { width: 140, @@ -45,7 +45,7 @@ interface DrawPdfFieldsProps { users: User[] userProfiles: { [key: string]: NDKUserProfile } sigitFiles: SigitFile[] - updateSigitFiles: Updater<SigitFile[]> + setSigitFiles: React.Dispatch<React.SetStateAction<SigitFile[]>> selectedTool?: DrawTool } @@ -53,11 +53,10 @@ export const DrawPDFFields = ({ selectedTool, userProfiles, sigitFiles, - updateSigitFiles, + setSigitFiles, users }: DrawPdfFieldsProps) => { const { to, from } = useScale() - const signers = users.filter((u) => u.role === UserRole.signer) const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : '' const [lastSigner, setLastSigner] = useState(defaultSignerNpub) @@ -354,8 +353,10 @@ export const DrawPDFFields = ({ ) => { event.stopPropagation() - updateSigitFiles((draft) => { - draft[fileIndex]?.pages![pageIndex]?.drawnFields?.splice(fieldIndex, 1) + setSigitFiles((prev) => { + const clone = _.cloneDeep(prev) + clone[fileIndex]?.pages![pageIndex]?.drawnFields?.splice(fieldIndex, 1) + return clone }) } @@ -416,22 +417,28 @@ export const DrawPDFFields = ({ // Add new drawn field to the files if (mouseState.clicked) { - updateSigitFiles((draft) => { - draft[fileIndex].pages![pageIndex].drawnFields.push(field) + setSigitFiles((prev) => { + const clone = _.cloneDeep(prev) + clone[fileIndex].pages![pageIndex].drawnFields.push(field) + return clone }) } // Move if (mouseState.dragging) { - updateSigitFiles((draft) => { - draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field + setSigitFiles((prev) => { + const clone = _.cloneDeep(prev) + clone[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field + return clone }) } // Resize if (mouseState.resizing) { - updateSigitFiles((draft) => { - draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field + setSigitFiles((prev) => { + const clone = _.cloneDeep(prev) + clone[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field + return clone }) } @@ -446,7 +453,7 @@ export const DrawPDFFields = ({ mouseState.clicked, mouseState.dragging, mouseState.resizing, - updateSigitFiles + setSigitFiles ]) /** diff --git a/src/components/MarkTypeStrategy/DateTime/Input.tsx b/src/components/MarkTypeStrategy/DateTime/Input.tsx new file mode 100644 index 0000000..b2e864c --- /dev/null +++ b/src/components/MarkTypeStrategy/DateTime/Input.tsx @@ -0,0 +1,24 @@ +import { MarkInputProps } from '../MarkStrategy' +import styles from '../../MarkFormField/style.module.scss' +import { useEffect, useRef } from 'react' + +export const MarkInputDateTime = ({ handler, placeholder }: MarkInputProps) => { + const ref = useRef<HTMLInputElement>(null) + useEffect(() => { + if (ref.current) { + const date = new Date() + ref.current.value = date.toISOString().slice(0, 16) + handler(date.toUTCString()) + } + }, [handler]) + return ( + <input + type="datetime-local" + ref={ref} + className={styles.input} + placeholder={placeholder} + readOnly={true} + disabled={true} + /> + ) +} diff --git a/src/components/MarkTypeStrategy/DateTime/index.tsx b/src/components/MarkTypeStrategy/DateTime/index.tsx new file mode 100644 index 0000000..1892d49 --- /dev/null +++ b/src/components/MarkTypeStrategy/DateTime/index.tsx @@ -0,0 +1,7 @@ +import { MarkStrategy } from '../MarkStrategy' +import { MarkInputDateTime } from './Input' + +export const DateTimeStrategy: MarkStrategy = { + input: MarkInputDateTime, + render: ({ value }) => <>{value}</> +} diff --git a/src/components/MarkTypeStrategy/FullName/Input.tsx b/src/components/MarkTypeStrategy/FullName/Input.tsx new file mode 100644 index 0000000..7b63ae6 --- /dev/null +++ b/src/components/MarkTypeStrategy/FullName/Input.tsx @@ -0,0 +1,20 @@ +import { useDidMount } from '../../../hooks' +import { useLocalStorage } from '../../../hooks/useLocalStorage' +import { MarkInputProps } from '../MarkStrategy' +import { MarkInputText } from '../Text/Input' + +export const MarkInputFullName = (props: MarkInputProps) => { + const [fullName, setFullName] = useLocalStorage('mark-fullname', '') + useDidMount(() => { + props.handler(fullName) + }) + return MarkInputText({ + ...props, + placeholder: 'Full Name', + value: fullName, + handler: (value) => { + setFullName(value) + props.handler(value) + } + }) +} diff --git a/src/components/MarkTypeStrategy/FullName/index.tsx b/src/components/MarkTypeStrategy/FullName/index.tsx new file mode 100644 index 0000000..1574c42 --- /dev/null +++ b/src/components/MarkTypeStrategy/FullName/index.tsx @@ -0,0 +1,7 @@ +import { MarkStrategy } from '../MarkStrategy' +import { MarkInputFullName } from './Input' + +export const FullNameStrategy: MarkStrategy = { + input: MarkInputFullName, + render: ({ value }) => <>{value}</> +} diff --git a/src/components/MarkTypeStrategy/JobTitle/Input.tsx b/src/components/MarkTypeStrategy/JobTitle/Input.tsx new file mode 100644 index 0000000..47d2969 --- /dev/null +++ b/src/components/MarkTypeStrategy/JobTitle/Input.tsx @@ -0,0 +1,20 @@ +import { useDidMount } from '../../../hooks' +import { useLocalStorage } from '../../../hooks/useLocalStorage' +import { MarkInputProps } from '../MarkStrategy' +import { MarkInputText } from '../Text/Input' + +export const MarkInputJobTitle = (props: MarkInputProps) => { + const [jobTitle, setjobTitle] = useLocalStorage('mark-jobtitle', '') + useDidMount(() => { + props.handler(jobTitle) + }) + return MarkInputText({ + ...props, + placeholder: 'Job Title', + value: jobTitle, + handler: (value) => { + setjobTitle(value) + props.handler(value) + } + }) +} diff --git a/src/components/MarkTypeStrategy/JobTitle/index.tsx b/src/components/MarkTypeStrategy/JobTitle/index.tsx new file mode 100644 index 0000000..11f5d60 --- /dev/null +++ b/src/components/MarkTypeStrategy/JobTitle/index.tsx @@ -0,0 +1,7 @@ +import { MarkStrategy } from '../MarkStrategy' +import { MarkInputJobTitle } from './Input' + +export const JobTitleStrategy: MarkStrategy = { + input: MarkInputJobTitle, + render: ({ value }) => <>{value}</> +} diff --git a/src/components/MarkTypeStrategy/MarkStrategy.tsx b/src/components/MarkTypeStrategy/MarkStrategy.tsx index 562302e..0ca0ebc 100644 --- a/src/components/MarkTypeStrategy/MarkStrategy.tsx +++ b/src/components/MarkTypeStrategy/MarkStrategy.tsx @@ -2,6 +2,9 @@ import { MarkType } from '../../types/drawing' import { CurrentUserMark, Mark } from '../../types/mark' import { TextStrategy } from './Text' import { SignatureStrategy } from './Signature' +import { FullNameStrategy } from './FullName' +import { JobTitleStrategy } from './JobTitle' +import { DateTimeStrategy } from './DateTime' export interface MarkInputProps { value: string @@ -28,5 +31,8 @@ export type MarkStrategies = { export const MARK_TYPE_CONFIG: MarkStrategies = { [MarkType.TEXT]: TextStrategy, - [MarkType.SIGNATURE]: SignatureStrategy + [MarkType.SIGNATURE]: SignatureStrategy, + [MarkType.FULLNAME]: FullNameStrategy, + [MarkType.JOBTITLE]: JobTitleStrategy, + [MarkType.DATETIME]: DateTimeStrategy } diff --git a/src/components/PDFView/style.module.scss b/src/components/PDFView/style.module.scss index 61983d7..92c044e 100644 --- a/src/components/PDFView/style.module.scss +++ b/src/components/PDFView/style.module.scss @@ -8,6 +8,4 @@ position: absolute; z-index: 40; display: flex; - justify-content: center; - align-items: center; } diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index e0e75fb..7199a51 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -56,8 +56,7 @@ export const useAuth = () => { * method will be chosen (extension or keys) * * @param pubkey of the user trying to login - * @returns url to redirect if authentication successfull - * or error if otherwise + * @returns url to redirect if user has no relays set */ const authAndGetMetadataAndRelaysMap = useCallback( async (pubkey: string) => { @@ -108,7 +107,7 @@ export const useAuth = () => { dispatch(setRelayMapAction(relayMap)) } - return appPrivateRoutes.homePage + return }, [ dispatch, diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..a2a9532 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,72 @@ +import React, { useMemo } from 'react' +import { + getLocalStorageItem, + mergeWithInitialValue, + removeLocalStorageItem, + setLocalStorageItem +} from '../utils' + +/** + * Subscribe to the Browser's storage event. Get the new value if any of the tabs changes it. + * @param callback - function to be called when the storage event is triggered + * @returns clean up function + */ +const useLocalStorageSubscribe = (callback: () => void) => { + window.addEventListener('storage', callback) + return () => window.removeEventListener('storage', callback) +} + +export function useLocalStorage<T>( + key: string, + initialValue: T +): [T, React.Dispatch<React.SetStateAction<T>>] { + const getSnapshot = () => { + // Get the stored value + const storedValue = getLocalStorageItem(key, initialValue) + + // Parse the value + const parsedStoredValue = JSON.parse(storedValue) + + // Merge the default and the stored in case some of the required fields are missing + return JSON.stringify( + mergeWithInitialValue(parsedStoredValue, initialValue) + ) + } + + // https://react.dev/reference/react/useSyncExternalStore + // Returns the snapshot of the data and subscribes to the storage event + const data = React.useSyncExternalStore(useLocalStorageSubscribe, getSnapshot) + + // Takes the value or a function that returns the value and updates the local storage + const setState: React.Dispatch<React.SetStateAction<T>> = React.useCallback( + (v: React.SetStateAction<T>) => { + try { + const nextState = + typeof v === 'function' + ? (v as (prevState: T) => T)(JSON.parse(data)) + : v + + if (nextState === undefined || nextState === null) { + removeLocalStorageItem(key) + } else { + setLocalStorageItem(key, JSON.stringify(nextState)) + } + } catch (e) { + console.warn(e) + } + }, + [data, key] + ) + + React.useEffect(() => { + // Set local storage only when it's empty + const data = window.localStorage.getItem(key) + if (data === null) { + setLocalStorageItem(key, JSON.stringify(initialValue)) + } + }, [key, initialValue]) + + const memoized = useMemo(() => JSON.parse(data) as T, [data]) + + return [memoized, setState] +} diff --git a/src/hooks/useNDK.ts b/src/hooks/useNDK.ts index ad9e54f..c6ec41d 100644 --- a/src/hooks/useNDK.ts +++ b/src/hooks/useNDK.ts @@ -12,7 +12,9 @@ import { import _ from 'lodash' import { Event, + finalizeEvent, generateSecretKey, + getEventHash, getPublicKey, kinds, UnsignedEvent @@ -40,17 +42,21 @@ import { getDTagForUserAppData, getUserAppDataFromBlossom, hexToNpub, + nip44Encrypt, parseJson, + randomTimeUpTo2DaysInThePast, SIGIT_RELAY, unixNow, uploadUserAppDataToBlossom } from '../utils' +import { SendDMError, SendDMErrorType } from '../types/errors/SendDMError' export const useNDK = () => { const dispatch = useAppDispatch() const { ndk, fetchEvent, + fetchEventFromUserRelays, fetchEventsFromUserRelays, publish, getNDKRelayList @@ -503,10 +509,139 @@ export const useNDK = () => { [ndk, usersPubkey, getNDKRelayList] ) + /** + * Modified {@link UnsignedEvent Unsigned Event} that includes an id + * + * Fields id and created_at are required. + * @see {@link UnsignedEvent} + * @see {@link https://github.com/nostr-protocol/nips/blob/master/17.md#direct-message-kind} + */ + type UnsignedEventWithId = UnsignedEvent & { + id?: string + } + const sendPrivateDirectMessage = useCallback( + async (message: string, receiver: string, subject?: string) => { + if (!receiver) throw new SendDMError(SendDMErrorType.MISSING_RECIEVER) + + // Get the direct message preferred relays list + // https://github.com/nostr-protocol/nips/blob/master/17.md#publishing + const preferredRelaysListEvent = await fetchEventFromUserRelays( + { + kinds: [NDKKind.DirectMessageReceiveRelayList], + authors: [receiver] + }, + receiver, + UserRelaysType.Read + ) + + const isRelayTag = (tag: string[]): boolean => tag[0] === 'relay' + const finalRelaysList: string[] = [] + if (preferredRelaysListEvent) { + const preferredRelaysList = preferredRelaysListEvent.tags + .filter((t) => isRelayTag(t)) + .map((t) => t[1]) + + finalRelaysList.push(...preferredRelaysList) + } + + if (!finalRelaysList.length) { + // Get receiver's read relay list + const ndkRelayList = await getNDKRelayList(receiver).catch((err) => { + // Log an error if retrieving relay list metadata fails + console.log( + `An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`, + err + ) + return null + }) + if (ndkRelayList?.readRelayUrls) { + finalRelaysList.push(...ndkRelayList.readRelayUrls) + } + } + + if (!finalRelaysList.includes(SIGIT_RELAY)) { + finalRelaysList.push(SIGIT_RELAY) + } + + // Generate "sender" + const senderSecret = generateSecretKey() + const senderPubkey = getPublicKey(senderSecret) + + // Prepare tags for the message + const tags: string[][] = [['p', receiver]] + + // Conversation title + if (subject) tags.push(['subject', subject]) + + // Create private DM event containing the message and relevant metadata + // TODO: kinds.PrivateDirectMessage (unavailabe in nostr-tools 10/10/2024 at v2.7.0) + const dm: UnsignedEventWithId = { + pubkey: senderPubkey, + created_at: unixNow(), + kind: 14, + tags, + content: message + } + + // Calculate the hash based on the UnverifiedEvent + dm.id = getEventHash(dm) + + // Encrypt the private dm using the sender secret and the receiver's public key + const encryptedDm = nip44Encrypt(dm, senderSecret, receiver) + if (!encryptedDm) { + throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, { + context: { + receiver, + message, + kind: dm.kind + } + }) + } + + // Seal the message + // TODO: kinds.Seal (unavailabe in nostr-tools 10/10/2024 at v2.7.0) + const sealedMessage: UnsignedEvent = { + kind: 13, // seal + pubkey: senderPubkey, + content: encryptedDm, + created_at: randomTimeUpTo2DaysInThePast(), + tags: [] // no tags + } + + // Finalize and sign the sealed event + const finalizedSeal = finalizeEvent(sealedMessage, senderSecret) + + // Encrypt the seal and gift wrap + const finalizedGiftWrap = createWrap(finalizedSeal, receiver) + + const ndkEvent = new NDKEvent(ndk, finalizedGiftWrap) + + // Publish the finalized gift wrap event (the encrypted DM) to the relays + const publishedOnRelays = await ndkEvent.publish( + NDKRelaySet.fromRelayUrls(finalRelaysList, ndk, true) + ) + + // Handle cases where publishing to the relays failed + if (publishedOnRelays.size === 0) { + throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, { + context: { + receiver, + count: publishedOnRelays.size + } + }) + } + + // Return true indicating that the DM was successfully sent + return true + }, + [fetchEventFromUserRelays, getNDKRelayList, ndk] + ) + return { getUsersAppData, subscribeForSigits, updateUsersAppData, - sendNotification + sendNotification, + sendPrivateDirectMessage } } diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 85daf75..2106931 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -1,16 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Outlet, useNavigate, useSearchParams } from 'react-router-dom' - import { getPublicKey, nip19 } from 'nostr-tools' - import { init as initNostrLogin } from 'nostr-login' import { NostrLoginAuthOptions } from 'nostr-login/dist/types' - import { AppBar } from '../components/AppBar/AppBar' import { LoadingSpinner } from '../components/LoadingSpinner' - import { NostrController } from '../controllers' - import { useAppDispatch, useAppSelector, @@ -19,7 +14,6 @@ import { useNDK, useNDKContext } from '../hooks' - import { restoreState, setUserProfile, @@ -30,9 +24,7 @@ import { setUserRobotImage } from '../store/actions' import { LoginMethod } from '../store/auth/types' - import { getRoboHashPicture, loadState } from '../utils' - import styles from './style.module.scss' export const MainLayout = () => { @@ -53,29 +45,32 @@ export const MainLayout = () => { // Ref to track if `subscribeForSigits` has been called const hasSubscribed = useRef(false) - const navigateAfterLogin = (path: string) => { - const callbackPath = searchParams.get('callbackPath') - - if (callbackPath) { - // base64 decoded path - const path = atob(callbackPath) - navigate(path) - return - } - - navigate(path) - } + const navigateAfterLogin = useCallback( + (path: string | undefined) => { + const isCallback = window.location.hash.startsWith('#/?callbackPath=') + if (isCallback) { + const path = atob(window.location.hash.replace('#/?callbackPath=', '')) + setSearchParams((prev) => { + prev.delete('callbackPath') + return prev + }) + navigate(path) + return + } + if (path) navigate(path) + }, + [navigate, setSearchParams] + ) const login = useCallback(async () => { - dispatch(updateLoginMethod(LoginMethod.nostrLogin)) - - const nostrController = NostrController.getInstance() - const pubkey = await nostrController.capturePublicKey() - - const redirectPath = await authAndGetMetadataAndRelaysMap(pubkey) - - if (redirectPath) { + try { + dispatch(updateLoginMethod(LoginMethod.nostrLogin)) + const nostrController = NostrController.getInstance() + const pubkey = await nostrController.capturePublicKey() + const redirectPath = await authAndGetMetadataAndRelaysMap(pubkey) navigateAfterLogin(redirectPath) + } catch (error) { + console.error(`Error occured during login`, error) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch]) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index d5424a9..2bb0642 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -45,7 +45,12 @@ import { uploadToFileStorage, DEFAULT_TOOLBOX, settleAllFullfilfedPromises, - uploadMetaToFileStorage + parseNostrEvent, + uploadMetaToFileStorage, + clearSigitDraft, + saveSigitDraft, + getSigitDraft, + timeout } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' @@ -72,13 +77,14 @@ import { getSigitFile, SigitFile } from '../../utils/file.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts' import { Autocomplete } from '@mui/material' import _, { truncate } from 'lodash' +import { SendDMError } from '../../types/errors/SendDMError.ts' import * as React from 'react' import { AvatarIconButton } from '../../components/UserAvatarIconButton' import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk' import { useNDKContext } from '../../hooks/useNDKContext.ts' import { useNDK } from '../../hooks/useNDK.ts' -import { useImmer } from 'use-immer' import { ButtonUnderline } from '../../components/ButtonUnderline/index.tsx' +import { TimeoutError } from '../../types/errors/TimeoutError.ts' type FoundUser = NostrEvent & { npub: string } @@ -86,7 +92,8 @@ export const CreatePage = () => { const navigate = useNavigate() const location = useLocation() const { findMetadata, fetchEventsFromUserRelays } = useNDKContext() - const { updateUsersAppData, sendNotification } = useNDK() + const { updateUsersAppData, sendNotification, sendPrivateDirectMessage } = + useNDK() const { uploadedFiles } = location.state || {} const [currentFile, setCurrentFile] = useState<File>() @@ -97,7 +104,9 @@ export const CreatePage = () => { const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) - const [selectedFiles, setSelectedFiles] = useState<File[]>([...uploadedFiles]) + const [selectedFiles, setSelectedFiles] = useState<File[]>([ + ...(uploadedFiles || []) + ]) const fileInputRef = useRef<HTMLInputElement>(null) const handleUploadButtonClick = () => { if (fileInputRef.current) { @@ -123,7 +132,7 @@ export const CreatePage = () => { [key: string]: NDKUserProfile }>({}) - const [drawnFiles, updateDrawnFiles] = useImmer<SigitFile[]>([]) + const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([]) const [parsingPdf, setIsParsing] = useState<boolean>(false) const searchFieldRef = useRef<HTMLInputElement>(null) @@ -162,8 +171,8 @@ export const CreatePage = () => { return pubkey } - const handleSearchUsers = async (searchValue?: string) => { - const searchString = searchValue || userSearchInput || undefined + const handleSearchUsers = async () => { + const searchString = userSearchInput || undefined if (!searchString) return @@ -171,14 +180,17 @@ export const CreatePage = () => { const searchTerm = searchString.trim() - fetchEventsFromUserRelays( - { - kinds: [0], - search: searchTerm - }, - usersPubkey, - UserRelaysType.Write - ) + Promise.race([ + fetchEventsFromUserRelays( + { + kinds: [0], + search: searchTerm + }, + usersPubkey, + UserRelaysType.Write + ), + timeout(30000) + ]) .then((events) => { const nostrEvents = events.map((event) => event.rawEvent()) @@ -216,6 +228,9 @@ export const CreatePage = () => { toast.info('No user found with the provided search term') }) .catch((error) => { + if (error instanceof TimeoutError) { + toast.error('Search timed out. Please try again.') + } console.error(error) }) .finally(() => { @@ -245,22 +260,23 @@ export const CreatePage = () => { // If pasted user npub of nip05 is present, we just add the user to the counterparts list if (pastedUserNpubOrNip05) { - setUserInput(pastedUserNpubOrNip05) + setUserInput(pastedUserNpubOrNip05.trim()) setPastedUserNpubOrNip05(undefined) } else { - // Otherwize if search already provided some results, user must manually click the search button + // Otherwise if search already provided some results, user must manually click the search button if (!foundUsers.length) { + const searchTerm = userSearchInput.trim() // If it's NIP05 (includes @ or is a valid domain) send request to .well-known const domainRegex = /^[a-zA-Z0-9@.-]+\.[a-zA-Z]{2,}$/ - if (domainRegex.test(userSearchInput)) { + if (searchTerm.startsWith('_@') || domainRegex.test(searchTerm)) { setSearchUsersLoading(true) - const pubkey = await handleSearchUserNip05(userSearchInput) + const pubkey = await handleSearchUserNip05(searchTerm) setSearchUsersLoading(false) if (pubkey) { - setUserInput(userSearchInput) + setUserInput(searchTerm) } else { toast.error(`No user found with the NIP05: ${userSearchInput}`) } @@ -283,27 +299,29 @@ export const CreatePage = () => { selectedFiles, getSigitFile ) - updateDrawnFiles((draft) => { + setDrawnFiles((prev) => { + const clone = _.cloneDeep(prev) // Existing files are untouched // Handle removed files // Remove in reverse to avoid index issues - for (let i = draft.length - 1; i >= 0; i--) { + for (let i = clone.length - 1; i >= 0; i--) { if ( !files.some( - (f) => f.name === draft[i].name && f.size === draft[i].size + (f) => f.name === clone[i].name && f.size === clone[i].size ) ) { - draft.splice(i, 1) + clone.splice(i, 1) } } // Add new files files.forEach((f) => { - if (!draft.some((d) => d.name === f.name && d.size === f.size)) { - draft.push(f) + if (!clone.some((d) => d.name === f.name && d.size === f.size)) { + clone.push(f) } }) + return clone }) } @@ -313,7 +331,52 @@ export const CreatePage = () => { setIsParsing(false) }) } - }, [selectedFiles, updateDrawnFiles]) + }, [selectedFiles]) + + const [draftEnabled, setDraftEnabled] = useState(true) + useEffect(() => { + // Only proceed if we have no uploaded files + if (uploadedFiles?.length ?? 0) return + + getSigitDraft().then((draft) => { + if (draft) { + setSelectedFiles(draft.files) + setDrawnFiles((prev) => { + const clone = _.cloneDeep(prev) + clone.splice(0, clone.length, ...draft.files) + return clone + }) + setUsers(draft.users) + setTitle(draft.title) + + // After loading draft clear it + clearSigitDraft() + } + }) + }, [uploadedFiles]) + useEffect(() => { + if (draftEnabled) { + saveSigitDraft({ + title, + users, + lastUpdated: Date.now(), + files: drawnFiles + }).catch((error) => { + if ( + error instanceof DOMException && + error.name === 'QuotaExceededError' + ) { + // Disable draft if we hit size error + setDraftEnabled(false) + console.warn( + 'Draft functionality disabled temporarily. File size exceeds local storage limit.' + ) + clearSigitDraft() + } + // Ignore other errors + }) + } + }, [draftEnabled, drawnFiles, title, users]) /** * Changes the drawing tool @@ -411,7 +474,7 @@ export const CreatePage = () => { setUserSearchInput('') - if (input.startsWith('npub')) { + if (input.startsWith('npub1')) { return handleAddNpubUser(input) } @@ -504,7 +567,7 @@ export const CreatePage = () => { }) }) }) - updateDrawnFiles(drawnFilesCopy) + setDrawnFiles(drawnFilesCopy) } /** @@ -926,7 +989,29 @@ export const CreatePage = () => { toast.error('Failed to publish notifications') }) - const isFirstSigner = signers[0].pubkey === usersPubkey + const isFirstSigner = + signers.length > 0 && signers[0].pubkey === usersPubkey + + // Don't send notification if creator is next signer + if (signers.length > 0 && !isFirstSigner) { + // Send DM to the next signer + setLoadingSpinnerDesc('Sending DMs') + const nextSigner = signers[0].pubkey + const createSignatureEvent = parseNostrEvent(meta.createSignature) + const { id } = createSignatureEvent + try { + await sendPrivateDirectMessage( + `Sigit created, visit ${window.location.origin}/#/sign/${id}`, + npubToHex(nextSigner)! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) + } + } + if (isFirstSigner) { navigate(appPrivateRoutes.sign, { state: { meta } }) } else { @@ -940,6 +1025,7 @@ export const CreatePage = () => { console.error(error) } finally { setIsLoading(false) + clearSigitDraft() } } @@ -1017,6 +1103,7 @@ export const CreatePage = () => { console.error(error) } finally { setIsLoading(false) + clearSigitDraft() } } @@ -1034,17 +1121,13 @@ export const CreatePage = () => { } // Seems like it's npub format - if (value.startsWith('npub')) { - // We will try to convert npub to hex and if it's successfull that means - // npub is valid - const validHexPubkey = npubToHex(value) - - if (validHexPubkey) { - // Arm the manual user npub add after enter is hit, we don't want to trigger search - setPastedUserNpubOrNip05(value) - } else { - disarmAddOnEnter() - } + if (value.trim().startsWith('npub1')) { + setPastedUserNpubOrNip05(value.trim()) + } else if (value.trim().startsWith('nsec1')) { + toast.warn('Oops - never paste your nsec into a website! Key deleted.') + if (searchFieldRef.current) searchFieldRef.current.value = '' + setUserSearchInput('') + return } else { // Disarm the add user on enter hit, and trigger search after 1 second disarmAddOnEnter() @@ -1204,7 +1287,7 @@ export const CreatePage = () => { {!pastedUserNpubOrNip05 ? ( <Button disabled={!userSearchInput || searchUsersLoading} - onClick={() => handleSearchUsers()} + onClick={handleSearchUsers} variant="contained" aria-label="Add" className={styles.counterpartToggleButton} @@ -1218,7 +1301,7 @@ export const CreatePage = () => { ) : ( <Button onClick={() => { - setUserInput(userSearchInput) + setUserInput(userSearchInput.trim()) }} variant="contained" aria-label="Add" @@ -1285,7 +1368,7 @@ export const CreatePage = () => { userProfiles={userProfiles} selectedTool={selectedTool} sigitFiles={drawnFiles} - updateSigitFiles={updateDrawnFiles} + setSigitFiles={setDrawnFiles} /> {parsingPdf && <LoadingSpinner variant="small" />} </StickySideColumns> diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index abd3b4e..44779b4 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -2,7 +2,7 @@ import { Button, TextField } from '@mui/material' import { useCallback, useEffect, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { toast } from 'react-toastify' -import { useAppSelector } from '../../hooks' +import { useAppSelector, useDidMount } from '../../hooks' import { appPrivateRoutes } from '../../routes' import { Meta } from '../../types' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -13,12 +13,15 @@ import { useDropzone } from 'react-dropzone' import { Container } from '../../components/Container' import styles from './style.module.scss' import { + clearSigitDraft, extractSigitCardDisplayInfo, + hasSigitDraft, navigateFromZip, SigitCardDisplayInfo, SigitStatus } from '../../utils' import { Footer } from '../../components/Footer/Footer' +import { LocalDraftSigit } from '../../components/DisplaySigit/LocalDraftSigit' // Unsupported Filter options are commented const FILTERS = [ @@ -44,6 +47,12 @@ export const HomePage = () => { const [searchParams, setSearchParams] = useSearchParams() const q = searchParams.get('q') ?? '' + const [showDraft, setShowDraft] = useState<boolean>(false) + useDidMount(async () => { + // Check if draft exists and add link to direct + setShowDraft(hasSigitDraft()) + }) + useEffect(() => { const searchInput = document.getElementById('q') as HTMLInputElement | null if (searchInput) { @@ -152,7 +161,7 @@ export const HomePage = () => { meta={sigits[key]} /> )) - } else { + } else if (!showDraft) { return ( <div className={styles.noResults}> <p>No results</p> @@ -260,7 +269,17 @@ export const HomePage = () => { )} </button> - <div className={styles.submissions}>{renderSubmissions()}</div> + <div className={styles.submissions}> + {showDraft && ( + <LocalDraftSigit + handleDraftDelete={() => { + clearSigitDraft() + setShowDraft(false) + }} + /> + )} + {renderSubmissions()} + </div> </Container> <Footer /> </div> diff --git a/src/pages/landing/index.tsx b/src/pages/landing/index.tsx index 773b923..5d24bdf 100644 --- a/src/pages/landing/index.tsx +++ b/src/pages/landing/index.tsx @@ -1,7 +1,5 @@ import { Box, Button } from '@mui/material' -import { useEffect } from 'react' -import { Outlet, useLocation } from 'react-router-dom' -import { saveVisitedLink } from '../../utils' +import { Outlet } from 'react-router-dom' import { CardComponent } from '../../components/Landing/CardComponent/CardComponent' import { Container } from '../../components/Container' import styles from './style.module.scss' @@ -20,13 +18,19 @@ import { import { FontAwesomeIconStack } from '../../components/FontAwesomeIconStack' import { Footer } from '../../components/Footer/Footer' import { launch as launchNostrLoginDialog } from 'nostr-login' +import { useDidMount } from '../../hooks' export const LandingPage = () => { - const location = useLocation() - const onSignInClick = async () => { launchNostrLoginDialog() } + useDidMount(() => { + const isCallback = window.location.hash.startsWith('#/?callbackPath=') + // Open nostr login if detect callback + if (isCallback) { + onSignInClick() + } + }) const cards = [ { @@ -101,10 +105,6 @@ export const LandingPage = () => { } ] - useEffect(() => { - saveVisitedLink(location.pathname, location.search) - }, [location]) - return ( <div className={styles.background}> <div diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 8e1e8c0..e5b29f6 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -13,7 +13,7 @@ import { Footer } from '../../components/Footer/Footer' import { LoadingSpinner } from '../../components/LoadingSpinner' import { useAppSelector } from '../../hooks/store' -import { getProfileSettingsRoute } from '../../routes' +import { appPrivateRoutes } from '../../routes' import { getProfileUsername, @@ -168,7 +168,7 @@ export const ProfilePage = () => { <Box className={styles.right}> {isUsersOwnProfile && ( <IconButton - onClick={() => navigate(getProfileSettingsRoute(pubkey))} + onClick={() => navigate(appPrivateRoutes.profileSettings)} > <EditIcon /> </IconButton> diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx index 5acdd9c..4bd9735 100644 --- a/src/pages/settings/Settings.tsx +++ b/src/pages/settings/Settings.tsx @@ -1,94 +1,82 @@ import AccountCircleIcon from '@mui/icons-material/AccountCircle' -import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos' -import CachedIcon from '@mui/icons-material/Cached' import RouterIcon from '@mui/icons-material/Router' -import { ListItem, useTheme } from '@mui/material' -import List from '@mui/material/List' -import ListItemIcon from '@mui/material/ListItemIcon' -import ListItemText from '@mui/material/ListItemText' -import ListSubheader from '@mui/material/ListSubheader' +import { Button } from '@mui/material' import { useAppSelector } from '../../hooks/store' -import { Link } from 'react-router-dom' -import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes' +import { NavLink, Outlet, To } from 'react-router-dom' +import { appPrivateRoutes } from '../../routes' import { Container } from '../../components/Container' import { Footer } from '../../components/Footer/Footer' import ExtensionIcon from '@mui/icons-material/Extension' import { LoginMethod } from '../../store/auth/types' +import styles from './style.module.scss' +import { ReactNode } from 'react' -export const SettingsPage = () => { - const theme = useTheme() - const { usersPubkey, loginMethod } = useAppSelector((state) => state.auth) - const listItem = (label: string, disabled = false) => { - return ( - <> - <ListItemText - primary={label} +const Item = (to: To, icon: ReactNode, label: string) => { + return ( + <NavLink to={to}> + {({ isActive }) => ( + <Button + fullWidth sx={{ - color: theme.palette.text.primary + transition: 'ease 0.3s', + justifyContent: 'start', + gap: '10px', + background: 'rgba(76,130,163,0)', + color: '#434343', + fontWeight: 600, + opacity: 0.75, + textTransform: 'none', + ...(isActive + ? { + background: '#447592', + color: 'white' + } + : {}), + '&:hover': { + opacity: 0.85, + gap: '15px', + background: '#5e8eab', + color: 'white' + } }} - /> + variant={'text'} + > + {icon} + {label} + </Button> + )} + </NavLink> + ) +} - {!disabled && ( - <ArrowForwardIosIcon - style={{ - color: theme.palette.action.active, - marginRight: -10 - }} - /> - )} - </> - ) - } +export const SettingsLayout = () => { + const { loginMethod } = useAppSelector((state) => state.auth) return ( <> <Container> - <List - sx={{ - width: '100%', - bgcolor: 'background.paper' - }} - subheader={ - <ListSubheader - sx={{ - fontSize: '1.5rem', - borderBottom: '0.5px solid', - paddingBottom: 2, - paddingTop: 2, - zIndex: 2 - }} - > - Settings - </ListSubheader> - } - > - <ListItem component={Link} to={getProfileSettingsRoute(usersPubkey!)}> - <ListItemIcon> - <AccountCircleIcon /> - </ListItemIcon> - {listItem('Profile')} - </ListItem> - <ListItem component={Link} to={appPrivateRoutes.relays}> - <ListItemIcon> - <RouterIcon /> - </ListItemIcon> - {listItem('Relays')} - </ListItem> - <ListItem component={Link} to={appPrivateRoutes.cacheSettings}> - <ListItemIcon> - <CachedIcon /> - </ListItemIcon> - {listItem('Local Cache')} - </ListItem> - {loginMethod === LoginMethod.nostrLogin && ( - <ListItem component={Link} to={appPrivateRoutes.nostrLogin}> - <ListItemIcon> - <ExtensionIcon /> - </ListItemIcon> - {listItem('Nostr Login')} - </ListItem> - )} - </List> + <h2 className={styles.title}>Settings</h2> + <div className={styles.main}> + <div> + <aside className={styles.aside}> + {Item( + appPrivateRoutes.profileSettings, + <AccountCircleIcon />, + 'Profile' + )} + {Item(appPrivateRoutes.relays, <RouterIcon />, 'Relays')} + {loginMethod === LoginMethod.nostrLogin && + Item( + appPrivateRoutes.nostrLogin, + <ExtensionIcon />, + 'Nostr Login' + )} + </aside> + </div> + <div className={styles.content}> + <Outlet /> + </div> + </div> </Container> <Footer /> </> diff --git a/src/pages/settings/cache/index.tsx b/src/pages/settings/cache/index.tsx deleted file mode 100644 index 35cc95e..0000000 --- a/src/pages/settings/cache/index.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import ClearIcon from '@mui/icons-material/Clear' -import InputIcon from '@mui/icons-material/Input' -import IosShareIcon from '@mui/icons-material/IosShare' -import { - List, - ListItemButton, - ListItemIcon, - ListItemText, - ListSubheader, - useTheme -} from '@mui/material' -import { useState } from 'react' -import { toast } from 'react-toastify' -import { localCache } from '../../../services' -import { LoadingSpinner } from '../../../components/LoadingSpinner' -import { Container } from '../../../components/Container' -import { Footer } from '../../../components/Footer/Footer' - -export const CacheSettingsPage = () => { - const theme = useTheme() - - const [isLoading, setIsLoading] = useState(false) - const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - - const handleClearData = async () => { - setIsLoading(true) - setLoadingSpinnerDesc('Clearing cache data') - localCache - .clearCacheData() - .then(() => { - toast.success('cleared cached data') - }) - .catch((err) => { - console.log('An error occurred in clearing cache data', err) - toast.error(err.message || 'An error occurred in clearing cache data') - }) - .finally(() => { - setIsLoading(false) - }) - } - - const listItem = (label: string) => { - return ( - <ListItemText - primary={label} - sx={{ - color: theme.palette.text.primary - }} - /> - ) - } - - return ( - <> - <Container> - {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} - <List - sx={{ - width: '100%', - bgcolor: 'background.paper', - marginTop: 2 - }} - subheader={ - <ListSubheader - sx={{ - fontSize: '1.5rem', - borderBottom: '0.5px solid', - paddingBottom: 2, - paddingTop: 2, - zIndex: 2 - }} - > - Cache Setting - </ListSubheader> - } - > - <ListItemButton disabled> - <ListItemIcon> - <IosShareIcon /> - </ListItemIcon> - {listItem('Export (coming soon)')} - </ListItemButton> - - <ListItemButton disabled> - <ListItemIcon> - <InputIcon /> - </ListItemIcon> - {listItem('Import (coming soon)')} - </ListItemButton> - - <ListItemButton onClick={handleClearData}> - <ListItemIcon> - <ClearIcon sx={{ color: theme.palette.error.main }} /> - </ListItemIcon> - {listItem('Clear Cache')} - </ListItemButton> - </List> - </Container> - <Footer /> - </> - ) -} diff --git a/src/pages/settings/nostrLogin/index.tsx b/src/pages/settings/nostrLogin/index.tsx index d2f0d29..31434ac 100644 --- a/src/pages/settings/nostrLogin/index.tsx +++ b/src/pages/settings/nostrLogin/index.tsx @@ -3,11 +3,9 @@ import { ListItemButton, ListItemIcon, ListItemText, - ListSubheader, useTheme } from '@mui/material' import { launch as launchNostrLoginDialog } from 'nostr-login' -import { Container } from '../../../components/Container' import PeopleIcon from '@mui/icons-material/People' import ImportExportIcon from '@mui/icons-material/ImportExport' import { useAppSelector } from '../../../hooks/store' @@ -20,59 +18,39 @@ export const NostrLoginPage = () => { ) return ( - <Container> - <List - sx={{ - width: '100%', - bgcolor: 'background.paper' + <List> + <ListItemButton + onClick={() => { + launchNostrLoginDialog('switch-account') }} - subheader={ - <ListSubheader - sx={{ - fontSize: '1.5rem', - borderBottom: '0.5px solid', - paddingBottom: 2, - paddingTop: 2, - zIndex: 2 - }} - > - Nostr Settings - </ListSubheader> - } > + <ListItemIcon> + <PeopleIcon /> + </ListItemIcon> + <ListItemText + primary={'Nostr Login Accounts'} + sx={{ + color: theme.palette.text.primary + }} + /> + </ListItemButton> + {nostrLoginAuthMethod === NostrLoginAuthMethod.Local && ( <ListItemButton onClick={() => { - launchNostrLoginDialog('switch-account') + launchNostrLoginDialog('import') }} > <ListItemIcon> - <PeopleIcon /> + <ImportExportIcon /> </ListItemIcon> <ListItemText - primary={'Nostr Login Accounts'} + primary={'Import / Export Keys'} sx={{ color: theme.palette.text.primary }} /> </ListItemButton> - {nostrLoginAuthMethod === NostrLoginAuthMethod.Local && ( - <ListItemButton - onClick={() => { - launchNostrLoginDialog('import') - }} - > - <ListItemIcon> - <ImportExportIcon /> - </ListItemIcon> - <ListItemText - primary={'Import / Export Keys'} - sx={{ - color: theme.palette.text.primary - }} - /> - </ListItemButton> - )} - </List> - </Container> + )} + </List> ) } diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx index 57383a7..5ca9000 100644 --- a/src/pages/settings/profile/index.tsx +++ b/src/pages/settings/profile/index.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef, useState } from 'react' -import { useParams } from 'react-router-dom' import { toast } from 'react-toastify' import { SmartToy } from '@mui/icons-material' @@ -12,7 +11,6 @@ import { InputProps, List, ListItem, - ListSubheader, TextField, Tooltip } from '@mui/material' @@ -28,8 +26,6 @@ import { useAppDispatch, useAppSelector } from '../../../hooks/store' import { getRoboHashPicture, unixNow } from '../../../utils' -import { Container } from '../../../components/Container' -import { Footer } from '../../../components/Footer/Footer' import { LoadingSpinner } from '../../../components/LoadingSpinner' import { setUserProfile as updateUserProfile } from '../../../store/actions' @@ -41,10 +37,8 @@ import styles from './style.module.scss' export const ProfileSettingsPage = () => { const dispatch: Dispatch = useAppDispatch() - const { npub } = useParams() const { ndk, findMetadata, publish } = useNDKContext() - const [pubkey, setPubkey] = useState<string>() const [userProfile, setUserProfile] = useState<NDKUserProfile | null>(null) const userRobotImage = useAppSelector((state) => state.user.robotImage) @@ -55,27 +49,13 @@ export const ProfileSettingsPage = () => { ) const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) - const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc] = useState('Fetching metadata') const robotSet = useRef(1) useEffect(() => { - if (npub) { - try { - const hexPubkey = nip19.decode(npub).data as string - setPubkey(hexPubkey) - - if (hexPubkey === usersPubkey) setIsUsersOwnProfile(true) - } catch (error) { - toast.error('Error occurred in decoding npub' + error) - } - } - }, [npub, usersPubkey]) - - useEffect(() => { - if (isUsersOwnProfile && currentUserProfile) { + if (usersPubkey && currentUserProfile) { setUserProfile(currentUserProfile) setIsLoading(false) @@ -83,8 +63,8 @@ export const ProfileSettingsPage = () => { return } - if (pubkey) { - findMetadata(pubkey) + if (usersPubkey) { + findMetadata(usersPubkey) .then((profile) => { setUserProfile(profile) }) @@ -95,7 +75,7 @@ export const ProfileSettingsPage = () => { setIsLoading(false) }) } - }, [ndk, isUsersOwnProfile, currentUserProfile, pubkey, findMetadata]) + }, [ndk, currentUserProfile, findMetadata, usersPubkey]) const editItem = ( key: keyof NDKUserProfile, @@ -113,7 +93,6 @@ export const ProfileSettingsPage = () => { multiline={multiline} rows={rows} className={styles.textField} - disabled={!isUsersOwnProfile} InputProps={inputProps} onChange={(event: React.ChangeEvent<HTMLInputElement>) => { const { value } = event.target @@ -170,7 +149,7 @@ export const ProfileSettingsPage = () => { content: serializedProfile, created_at: unixNow(), kind: kinds.Metadata, - pubkey: pubkey!, + pubkey: usersPubkey!, tags: [] } @@ -215,7 +194,7 @@ export const ProfileSettingsPage = () => { robotSet.current++ if (robotSet.current > 5) robotSet.current = 1 - const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current) + const robotAvatarLink = getRoboHashPicture(usersPubkey!, robotSet.current) setUserProfile((prev) => ({ ...prev, @@ -244,143 +223,106 @@ export const ProfileSettingsPage = () => { * @returns robohash image url */ const getProfileImage = (profile: NDKUserProfile) => { - if (!isUsersOwnProfile) { - return profile.image || getRoboHashPicture(npub!) - } - // userRobotImage is used only when visiting own profile // while kind 0 picture is not set - return profile.image || userRobotImage || getRoboHashPicture(npub!) + return profile.image || userRobotImage || getRoboHashPicture(usersPubkey!) } return ( <> {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} - <Container className={styles.container}> - <List - sx={{ - bgcolor: 'background.paper', - marginTop: 2 - }} - subheader={ - <ListSubheader + <List> + {userProfile && ( + <div> + <ListItem sx={{ - paddingBottom: 1, - paddingTop: 1, - fontSize: '1.5rem', - zIndex: 2 + marginTop: 1, + display: 'flex', + flexDirection: 'column' }} - className={styles.subHeader} > - Profile Settings - </ListSubheader> - } - > - {userProfile && ( - <div> - <ListItem - sx={{ - marginTop: 1, - display: 'flex', - flexDirection: 'column' - }} - > - {userProfile.banner ? ( - <img - className={styles.bannerImg} - src={userProfile.banner} - alt="Banner Image" - /> - ) : ( - <Box className={styles.noBanner}> No banner found </Box> - )} - </ListItem> - - {editItem('banner', 'Banner URL', undefined, undefined)} - - <ListItem - sx={{ - marginTop: 1, - display: 'flex', - flexDirection: 'column' - }} - > + {userProfile.banner ? ( <img - onError={(event: React.SyntheticEvent<HTMLImageElement>) => { - event.currentTarget.src = getRoboHashPicture(npub!) - }} - className={styles.img} - src={getProfileImage(userProfile)} - alt="Profile Image" + className={styles.bannerImg} + src={userProfile.banner} + alt="Banner Image" /> - </ListItem> - - {editItem('image', 'Picture URL', undefined, undefined, { - endAdornment: isUsersOwnProfile ? robohashButton() : undefined - })} - - {editItem('name', 'Username')} - {editItem('displayName', 'Display Name')} - {editItem('nip05', 'Nostr Address (nip05)')} - {editItem('lud16', 'Lightning Address (lud16)')} - {editItem('about', 'About', true, 4)} - {editItem('website', 'Website')} - {isUsersOwnProfile && ( - <> - {usersPubkey && - copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} - - {loginMethod === LoginMethod.privateKey && - keys && - keys.private && - copyItem( - '••••••••••••••••••••••••••••••••••••••••••••••••••', - 'Private Key', - keys.private - )} - </> + ) : ( + <Box className={styles.noBanner}> No banner found </Box> )} - {isUsersOwnProfile && ( - <> - {loginMethod === LoginMethod.nostrLogin && - nostrLoginAuthMethod === NostrLoginAuthMethod.Local && ( - <ListItem - sx={{ marginTop: 1 }} - onClick={() => { - launchNostrLoginDialog('import') - }} - > - <TextField - label="Private Key (nostr-login)" - defaultValue="••••••••••••••••••••••••••••••••••••••••••••••••••" - size="small" - className={styles.textField} - disabled - type={'password'} - InputProps={{ - endAdornment: ( - <LaunchIcon className={styles.copyItem} /> - ) - }} - /> - </ListItem> - )} - </> + </ListItem> + + {editItem('banner', 'Banner URL', undefined, undefined)} + + <ListItem + sx={{ + marginTop: 1, + display: 'flex', + flexDirection: 'column' + }} + > + <img + onError={(event: React.SyntheticEvent<HTMLImageElement>) => { + event.currentTarget.src = getRoboHashPicture(usersPubkey!) + }} + className={styles.img} + src={getProfileImage(userProfile)} + alt="Profile Image" + /> + </ListItem> + + {editItem('image', 'Picture URL', undefined, undefined, { + endAdornment: robohashButton() + })} + + {editItem('name', 'Username')} + {editItem('displayName', 'Display Name')} + {editItem('nip05', 'Nostr Address (nip05)')} + {editItem('lud16', 'Lightning Address (lud16)')} + {editItem('about', 'About', true, 4)} + {editItem('website', 'Website')} + {usersPubkey && + copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} + {loginMethod === LoginMethod.privateKey && + keys && + keys.private && + copyItem( + '••••••••••••••••••••••••••••••••••••••••••••••••••', + 'Private Key', + keys.private )} - </div> - )} - </List> - {isUsersOwnProfile && ( - <LoadingButton - loading={savingProfileMetadata} - variant="contained" - onClick={handleSaveMetadata} - > - SAVE - </LoadingButton> + {loginMethod === LoginMethod.nostrLogin && + nostrLoginAuthMethod === NostrLoginAuthMethod.Local && ( + <ListItem + sx={{ marginTop: 1 }} + onClick={() => { + launchNostrLoginDialog('import') + }} + > + <TextField + label="Private Key (nostr-login)" + defaultValue="••••••••••••••••••••••••••••••••••••••••••••••••••" + size="small" + className={styles.textField} + disabled + type={'password'} + InputProps={{ + endAdornment: <LaunchIcon className={styles.copyItem} /> + }} + /> + </ListItem> + )} + </div> )} - </Container> - <Footer /> + </List> + <LoadingButton + sx={{ maxWidth: '300px', alignSelf: 'center', width: '100%' }} + loading={savingProfileMetadata} + variant="contained" + onClick={handleSaveMetadata} + > + PUBLISH CHANGES + </LoadingButton> </> ) } diff --git a/src/pages/settings/profile/style.module.scss b/src/pages/settings/profile/style.module.scss index 672e59c..6cdc029 100644 --- a/src/pages/settings/profile/style.module.scss +++ b/src/pages/settings/profile/style.module.scss @@ -1,9 +1,3 @@ -.container { - display: flex; - flex-direction: column; - gap: 25px; -} - .textField { width: 100%; } diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index c0542c5..9590695 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -12,7 +12,6 @@ import ListItemText from '@mui/material/ListItemText' import Switch from '@mui/material/Switch' import { useEffect, useState } from 'react' import { toast } from 'react-toastify' -import { Container } from '../../../components/Container' import { useAppDispatch, useAppSelector, @@ -32,7 +31,6 @@ import { timeout } from '../../../utils' import styles from './style.module.scss' -import { Footer } from '../../../components/Footer/Footer' import { getRelayListForUser, NDKRelayList, @@ -246,7 +244,7 @@ export const RelaysPage = () => { } return ( - <Container className={styles.container}> + <> <Box className={styles.relayAddContainer}> <TextField label="Add new relay" @@ -291,8 +289,7 @@ export const RelaysPage = () => { ))} </Box> )} - <Footer /> - </Container> + </> ) } diff --git a/src/pages/settings/relays/style.module.scss b/src/pages/settings/relays/style.module.scss index 3db7760..df7eb31 100644 --- a/src/pages/settings/relays/style.module.scss +++ b/src/pages/settings/relays/style.module.scss @@ -1,107 +1,103 @@ @import '../../../styles/colors.scss'; -.container { - color: $text-color; +.relayURItextfield { + width: 100%; +} - .relayURItextfield { - width: 100%; +.relayAddContainer { + display: flex; + flex-direction: row; + gap: 10px; + width: 100%; + align-items: start; +} + +.sectionIcon { + font-size: 30px; +} + +.sectionTitle { + margin-top: 35px; + margin-bottom: 10px; + display: flex; + flex-direction: row; + gap: 5px; + font-size: 1.5rem; + line-height: 2rem; + font-weight: 600; +} + +.relaysContainer { + display: flex; + flex-direction: column; + gap: 15px; +} + +.relay { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + + .relayDivider { + margin-left: 10px; + margin-right: 10px; } - .relayAddContainer { + .leaveRelayContainer { display: flex; flex-direction: row; gap: 10px; - width: 100%; - align-items: start; + cursor: pointer; } - .sectionIcon { - font-size: 30px; + .showInfo { + cursor: pointer; } - .sectionTitle { - margin-top: 35px; - margin-bottom: 10px; + .showInfoIcon { + margin-right: 3px; + margin-bottom: auto; + vertical-align: middle; + } + + .relayInfoContainer { display: flex; - flex-direction: row; + flex-direction: column; gap: 5px; - font-size: 1.5rem; - line-height: 2rem; + text-wrap: wrap; + } + + .relayInfoTitle { font-weight: 600; } - .relaysContainer { - display: flex; - flex-direction: column; - gap: 15px; + .relayInfoSubTitle { + font-weight: 500; } - .relay { - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 4px; - - .relayDivider { - margin-left: 10px; - margin-right: 10px; - } - - .leaveRelayContainer { - display: flex; - flex-direction: row; - gap: 10px; - cursor: pointer; - } - - .showInfo { - cursor: pointer; - } - - .showInfoIcon { - margin-right: 3px; - margin-bottom: auto; - vertical-align: middle; - } - - .relayInfoContainer { - display: flex; - flex-direction: column; - gap: 5px; - text-wrap: wrap; - } - - .relayInfoTitle { - font-weight: 600; - } - - .relayInfoSubTitle { - font-weight: 500; - } - - .copyItem { - margin-left: 10px; - color: #34495e; - vertical-align: bottom; - cursor: pointer; - } - - .connectionStatus { - border-radius: 9999px; - width: 10px; - height: 10px; - margin-right: 5px; - margin-top: 2px; - } - - .connectionStatusConnected { - background-color: $relay-status-connected; - } - - .connectionStatusNotConnected { - background-color: $relay-status-notconnected; - } - - .connectionStatusUnknown { - background-color: $input-text-color; - } + .copyItem { + margin-left: 10px; + color: #34495e; + vertical-align: bottom; + cursor: pointer; } -} + + .connectionStatus { + border-radius: 9999px; + width: 10px; + height: 10px; + margin-right: 5px; + margin-top: 2px; + } + + .connectionStatusConnected { + background-color: $relay-status-connected; + } + + .connectionStatusNotConnected { + background-color: $relay-status-notconnected; + } + + .connectionStatusUnknown { + background-color: $input-text-color; + } +} \ No newline at end of file diff --git a/src/pages/settings/style.module.scss b/src/pages/settings/style.module.scss new file mode 100644 index 0000000..f2d45ef --- /dev/null +++ b/src/pages/settings/style.module.scss @@ -0,0 +1,43 @@ +.title { + margin: 0 0 15px 0; +} + +.main { + width: 100%; + display: grid; + grid-template-columns: 0.4fr 1.6fr; + position: relative; + grid-gap: 25px; + + >* { + width: 100%; + display: flex; + flex-direction: column; + grid-gap: 25px; + } +} + +.aside { + width: 100%; + background: white; + padding: 15px; + border-radius: 5px; + box-shadow: 0 0 4px 0 rgb(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + grid-gap: 15px; + + position: sticky; + top: 15px; +} + +.content { + width: 100%; + background: white; + padding: 15px; + border-radius: 5px; + box-shadow: 0 0 4px 0 rgb(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + grid-gap: 15px; +} \ No newline at end of file diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 07ffd4d..a1a3039 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -28,7 +28,8 @@ import { signEventForMetaFile, unixNow, updateMarks, - uploadMetaToFileStorage + uploadMetaToFileStorage, + parseNostrEvent } from '../../utils' import { CurrentUserMark, Mark } from '../../types/mark.ts' import PdfMarking from '../../components/PDFView/PdfMarking.tsx' @@ -36,12 +37,14 @@ import { convertToSigitFile, SigitFile } from '../../utils/file.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts' import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx' import { useNDK } from '../../hooks/useNDK.ts' +import { SendDMError } from '../../types/errors/SendDMError.ts' export const SignPage = () => { const navigate = useNavigate() const location = useLocation() const params = useParams() - const { updateUsersAppData, sendNotification } = useNDK() + const { updateUsersAppData, sendNotification, sendPrivateDirectMessage } = + useNDK() const usersAppData = useAppSelector((state) => state.userAppData) @@ -602,6 +605,66 @@ export const SignPage = () => { toast.error('Failed to publish notifications') }) + // Send DMs + setLoadingSpinnerDesc('Sending DMs') + const createSignatureEvent = parseNostrEvent(meta.createSignature) + const { id } = createSignatureEvent + + if (isLastSigner) { + // Final sign sends to everyone (creator, signers, viewers - /verify) + const areSent: boolean[] = Array(users.length).fill(false) + for (let i = 0; i < users.length; i++) { + try { + areSent[i] = await sendPrivateDirectMessage( + `Sigit completed, visit ${window.location.origin}/#/verify/${id}`, + npubToHex(users[i])! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) + } + } + + if (areSent.some((r) => r)) { + toast.success( + `DMs sent ${areSent.filter((r) => r).length}/${users.length}` + ) + } + } else { + // Notify the creator and + // the next signer (/sign). + try { + await sendPrivateDirectMessage( + `Sigit signed by ${usersNpub}, visit ${window.location.origin}/#/sign/${id}`, + npubToHex(submittedBy!)! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) + } + + // No need to notify creator twice, skipping + const currentSignerIndex = signers.indexOf(usersNpub) + const nextSigner = npubToHex(signers[currentSignerIndex + 1]) + if (nextSigner !== submittedBy) { + try { + await sendPrivateDirectMessage( + `You're the next signer, visit ${window.location.origin}/#/sign/${id}`, + nextSigner! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) + } + } + } + setIsLoading(false) } diff --git a/src/routes/PrivateRoute.tsx b/src/routes/PrivateRoute.tsx new file mode 100644 index 0000000..410ecea --- /dev/null +++ b/src/routes/PrivateRoute.tsx @@ -0,0 +1,21 @@ +import { Navigate, useLocation } from 'react-router-dom' +import { useAppSelector } from '../hooks' +import { appPublicRoutes } from '.' + +export function PrivateRoute({ children }: { children: JSX.Element }) { + const location = useLocation() + const isLoggedIn = useAppSelector((state) => state.auth?.loggedIn) + if (!isLoggedIn) { + return ( + <Navigate + to={{ + pathname: appPublicRoutes.landingPage, + search: `?callbackPath=${btoa(location.pathname)}` + }} + replace + /> + ) + } + + return children +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index f3580f9..f1bd004 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -4,9 +4,7 @@ export const appPrivateRoutes = { homePage: '/', create: '/create', sign: '/sign', - settings: '/settings', - profileSettings: '/settings/profile/:npub', - cacheSettings: '/settings/cache', + profileSettings: '/settings/profile', relays: '/settings/relays', nostrLogin: '/settings/nostrLogin' } @@ -24,6 +22,3 @@ export const appPublicRoutes = { export const getProfileRoute = (hexKey: string) => appPublicRoutes.profile.replace(':npub', hexToNpub(hexKey)) - -export const getProfileSettingsRoute = (hexKey: string) => - appPrivateRoutes.profileSettings.replace(':npub', hexToNpub(hexKey)) diff --git a/src/routes/util.tsx b/src/routes/util.tsx index 8773b81..e0356dd 100644 --- a/src/routes/util.tsx +++ b/src/routes/util.tsx @@ -4,13 +4,13 @@ import { CreatePage } from '../pages/create' import { HomePage } from '../pages/home' import { LandingPage } from '../pages/landing' import { ProfilePage } from '../pages/profile' -import { CacheSettingsPage } from '../pages/settings/cache' import { NostrLoginPage } from '../pages/settings/nostrLogin' import { ProfileSettingsPage } from '../pages/settings/profile' import { RelaysPage } from '../pages/settings/relays' -import { SettingsPage } from '../pages/settings/Settings' +import { SettingsLayout } from '../pages/settings/Settings' import { SignPage } from '../pages/sign' import { VerifyPage } from '../pages/verify' +import { PrivateRoute } from './PrivateRoute' /** * Helper type allows for extending react-router-dom's **RouteProps** with generic type @@ -37,7 +37,7 @@ export function recursiveRouteRenderer<T>( return routes.map((route, index) => renderConditionCallbackFn(route) ? ( <Route - key={`${route.path}${index}`} + key={route.path ? `${route.path}${index}` : index} path={route.path} element={route.element} > @@ -67,37 +67,50 @@ export const publicRoutes: PublicRouteProps[] = [ } ] -export const privateRoutes = [ +export const privateRoutes: CustomRouteProps<unknown>[] = [ { path: appPrivateRoutes.homePage, - element: <HomePage /> + element: ( + <PrivateRoute> + <HomePage /> + </PrivateRoute> + ) }, { path: appPrivateRoutes.create, - element: <CreatePage /> + element: ( + <PrivateRoute> + <CreatePage /> + </PrivateRoute> + ) }, { path: `${appPrivateRoutes.sign}/:id?`, - element: <SignPage /> + element: ( + <PrivateRoute> + <SignPage /> + </PrivateRoute> + ) }, { - path: appPrivateRoutes.settings, - element: <SettingsPage /> - }, - { - path: appPrivateRoutes.profileSettings, - element: <ProfileSettingsPage /> - }, - { - path: appPrivateRoutes.cacheSettings, - element: <CacheSettingsPage /> - }, - { - path: appPrivateRoutes.relays, - element: <RelaysPage /> - }, - { - path: appPrivateRoutes.nostrLogin, - element: <NostrLoginPage /> + element: ( + <PrivateRoute> + <SettingsLayout /> + </PrivateRoute> + ), + children: [ + { + path: appPrivateRoutes.profileSettings, + element: <ProfileSettingsPage /> + }, + { + path: appPrivateRoutes.relays, + element: <RelaysPage /> + }, + { + path: appPrivateRoutes.nostrLogin, + element: <NostrLoginPage /> + } + ] } ] diff --git a/src/services/cache/index.ts b/src/services/cache/index.ts deleted file mode 100644 index 957e45b..0000000 --- a/src/services/cache/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { IDBPDatabase, openDB } from 'idb' -import { Event } from 'nostr-tools' -import { CachedEvent } from '../../types' -import { SchemaV2 } from './schema' - -class LocalCache { - // Static property to hold the single instance of LocalCache - private static instance: LocalCache | null = null - private db!: IDBPDatabase<SchemaV2> - - // Private constructor to prevent direct instantiation - private constructor() {} - - // Method to initialize the database - private async init() { - this.db = await openDB<SchemaV2>('sigit-cache', 2, { - upgrade(db, oldVersion) { - if (oldVersion < 1) { - db.createObjectStore('userMetadata', { keyPath: 'event.pubkey' }) - } - - if (oldVersion < 2) { - const v6 = db as unknown as IDBPDatabase<SchemaV2> - - v6.createObjectStore('userRelayListMetadata', { - keyPath: 'event.pubkey' - }) - } - } - }) - } - - // Static method to get the single instance of LocalCache - public static async getInstance(): Promise<LocalCache> { - // If the instance doesn't exist, create it - if (!LocalCache.instance) { - LocalCache.instance = new LocalCache() - await LocalCache.instance.init() - } - // Return the single instance of LocalCache - return LocalCache.instance - } - - // Method to add user metadata - public async addUserMetadata(event: Event) { - await this.db.put('userMetadata', { event, cachedAt: Date.now() }) - } - - // Method to get user metadata by key - public async getUserMetadata(key: string): Promise<CachedEvent | null> { - const data = await this.db.get('userMetadata', key) - return data || null - } - - // Method to delete user metadata by key - public async deleteUserMetadata(key: string) { - await this.db.delete('userMetadata', key) - } - - public async addUserRelayListMetadata(event: Event) { - await this.db.put('userRelayListMetadata', { event, cachedAt: Date.now() }) - } - - public async getUserRelayListMetadata( - key: string - ): Promise<CachedEvent | null> { - const data = await this.db.get('userRelayListMetadata', key) - return data || null - } - - public async deleteUserRelayListMetadata(key: string) { - await this.db.delete('userRelayListMetadata', key) - } - - // Method to clear cache data - public async clearCacheData() { - // Clear the 'userMetadata' store in the IndexedDB database - await this.db.clear('userMetadata') - - // Reload the current page to ensure any cached data is reset - window.location.reload() - } -} - -// Export the single instance of LocalCache -export const localCache = await LocalCache.getInstance() diff --git a/src/services/cache/schema.ts b/src/services/cache/schema.ts deleted file mode 100644 index bc21956..0000000 --- a/src/services/cache/schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DBSchema } from 'idb' -import { CachedEvent } from '../../types' - -export interface SchemaV1 extends DBSchema { - userMetadata: { - key: string - value: CachedEvent - } -} - -export interface SchemaV2 extends SchemaV1 { - userRelayListMetadata: { - key: string - value: CachedEvent - } -} diff --git a/src/services/index.ts b/src/services/index.ts index b8d275a..eb0b67f 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,2 +1 @@ -export * from './cache' export * from './signer' diff --git a/src/types/draft.ts b/src/types/draft.ts new file mode 100644 index 0000000..23709a7 --- /dev/null +++ b/src/types/draft.ts @@ -0,0 +1,21 @@ +import { SigitFile } from '../utils/file' +import { User } from './core' +import { DrawnField } from './drawing' + +export interface SigitFileDraft { + name: string + file: string + pages: DrawnField[][] +} +export interface SigitDraft { + title: string + users: User[] + files: SigitFile[] + lastUpdated: number +} +export interface SerializedSigitDraft { + title: string + lastUpdated: number + users: User[] + files: SigitFileDraft[] +} diff --git a/src/types/errors/SendDMError.ts b/src/types/errors/SendDMError.ts new file mode 100644 index 0000000..70cc94a --- /dev/null +++ b/src/types/errors/SendDMError.ts @@ -0,0 +1,23 @@ +import { Jsonable } from '.' + +export enum SendDMErrorType { + 'MISSING_RECIEVER' = 'Sending DM failed. Reciever is required.', + 'ENCRYPTION_FAILED' = 'Sending DM failed. An error occurred in encrypting dm message.', + 'RELAY_PUBLISH_FAILED' = 'Sending DM failed. Publishing events failed.' +} + +export class SendDMError extends Error { + public readonly context?: Jsonable + + constructor( + message: string, + options: { cause?: Error; context?: Jsonable } = {} + ) { + const { cause, context } = options + + super(message, { cause }) + this.name = this.constructor.name + + this.context = context + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 5c5b715..40b240b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,5 @@ export * from './nostr' export * from './relay' export * from './zip' export * from './event' +export * from './drawing' +export * from './draft' diff --git a/src/utils/draft.ts b/src/utils/draft.ts new file mode 100644 index 0000000..e9ba239 --- /dev/null +++ b/src/utils/draft.ts @@ -0,0 +1,120 @@ +import { + DrawnField, + SerializedSigitDraft, + SigitDraft, + SigitFileDraft +} from '../types' +import { + getMediaType, + extractFileExtension, + toFile, + getSigitFile +} from './file' +const DRAFT_KEY = 'sigitDraft' +let saveSigitDraftTimeout: number | null = null +const serializeSigitDraft = async ( + draft: SigitDraft +): Promise<SerializedSigitDraft> => { + const serializedFiles = draft.files.map((file) => { + return new Promise<SigitFileDraft>((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const pages = file.pages + ? file.pages.map((page) => + page.drawnFields.map( + (field) => + ({ + left: field.left, + top: field.top, + width: field.width, + height: field.height, + type: field.type, + counterpart: field.counterpart + }) as DrawnField + ) + ) + : [] + resolve({ + name: file.name, + pages: pages, + file: reader.result as string + }) + } + reader.onerror = (error) => reject(error) + reader.readAsDataURL(file) + }) + }) + + const serializedFileDraft = await Promise.all(serializedFiles) + return { + title: draft.title, + lastUpdated: draft.lastUpdated, + users: [...draft.users], + files: serializedFileDraft + } +} + +const deserializeSigitDraft = async ( + serializedDraft: SerializedSigitDraft +): Promise<SigitDraft> => { + const files = await Promise.all( + serializedDraft.files.map(async (draft) => { + const response = await fetch(draft.file) + const arrayBuffer = await response.arrayBuffer() + const type = getMediaType(extractFileExtension(draft.name)) + const file = toFile(arrayBuffer, draft.name, type) + const sigitFile = await getSigitFile(file) + if (draft.pages) { + for (let i = 0; i < draft.pages.length; i++) { + const drawnFields = draft.pages[i] + if (sigitFile.pages) sigitFile.pages[i].drawnFields = [...drawnFields] + } + } + return sigitFile + }) + ) + + return { + ...serializedDraft, + files: files + } +} + +export const saveSigitDraft = (draft: SigitDraft): Promise<void> => { + if (saveSigitDraftTimeout) { + clearTimeout(saveSigitDraftTimeout) + } + + return new Promise((resolve, reject) => { + saveSigitDraftTimeout = window.setTimeout(() => { + serializeSigitDraft(draft) + .then((draftToSave) => { + localStorage.setItem(DRAFT_KEY, JSON.stringify(draftToSave)) + resolve() + }) + .catch((error) => { + reject(error) + }) + }, 1000) + }) +} + +export const hasSigitDraft = () => { + return DRAFT_KEY in localStorage +} + +export const getSigitDraft = async () => { + const sigitDraft = localStorage.getItem(DRAFT_KEY) + if (!sigitDraft) return null + + try { + const serializedDraft = JSON.parse(sigitDraft) as SerializedSigitDraft + return await deserializeSigitDraft(serializedDraft) + } catch { + return null + } +} + +export const clearSigitDraft = () => { + localStorage.removeItem(DRAFT_KEY) +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 274ceab..9c4464a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -12,3 +12,4 @@ export * from './string' export * from './url' export * from './utils' export * from './zip' +export * from './draft' diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts index 472e092..b0eb381 100644 --- a/src/utils/localStorage.ts +++ b/src/utils/localStorage.ts @@ -26,30 +26,6 @@ export const clearState = () => { localStorage.removeItem('state') } -export const saveVisitedLink = (pathname: string, search: string) => { - localStorage.setItem( - 'visitedLink', - JSON.stringify({ - pathname, - search - }) - ) -} - -export const getVisitedLink = () => { - const visitedLink = localStorage.getItem('visitedLink') - if (!visitedLink) return null - - try { - return JSON.parse(visitedLink) as { - pathname: string - search: string - } - } catch { - return null - } -} - export const saveAuthToken = (token: string) => { localStorage.setItem('authToken', token) } @@ -66,3 +42,47 @@ export const clear = () => { clearAuthToken() clearState() } + +export function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T { + if ( + !Array.isArray(storedValue) && + typeof storedValue === 'object' && + storedValue !== null + ) { + return { ...initialValue, ...storedValue } + } + return storedValue +} + +export function getLocalStorageItem<T>(key: string, defaultValue: T): string { + try { + const data = window.localStorage.getItem(key) + if (data === null) return JSON.stringify(defaultValue) + return data + } catch (err) { + console.error(`Error while fetching local storage value: `, err) + return JSON.stringify(defaultValue) + } +} + +export function setLocalStorageItem(key: string, value: string) { + try { + window.localStorage.setItem(key, value) + dispatchLocalStorageEvent(key, value) + } catch (err) { + console.error(`Error while saving local storage value: `, err) + } +} + +export function removeLocalStorageItem(key: string) { + try { + window.localStorage.removeItem(key) + dispatchLocalStorageEvent(key, null) + } catch (err) { + console.error(`Error while deleting local storage value: `, err) + } +} + +function dispatchLocalStorageEvent(key: string, newValue: string | null) { + window.dispatchEvent(new StorageEvent('storage', { key, newValue })) +} diff --git a/src/utils/mark.ts b/src/utils/mark.ts index 1868403..37bda6b 100644 --- a/src/utils/mark.ts +++ b/src/utils/mark.ts @@ -171,20 +171,17 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [ { identifier: MarkType.FULLNAME, icon: faIdCard, - label: 'Full Name', - isComingSoon: true + label: 'Full Name' }, { identifier: MarkType.JOBTITLE, icon: faBriefcase, - label: 'Job Title', - isComingSoon: true + label: 'Job Title' }, { identifier: MarkType.DATETIME, icon: faClock, - label: 'Date Time', - isComingSoon: true + label: 'Date Time' }, { identifier: MarkType.NUMBER, diff --git a/src/utils/meta.ts b/src/utils/meta.ts index 75e1654..fd68541 100644 --- a/src/utils/meta.ts +++ b/src/utils/meta.ts @@ -31,7 +31,8 @@ export enum SignStatus { export enum SigitStatus { Partial = 'In-Progress', - Complete = 'Completed' + Complete = 'Completed', + LocalDraft = 'Draft' } export interface SigitCardDisplayInfo { diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 600bd08..824a967 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -6,6 +6,7 @@ import { Event, EventTemplate, UnsignedEvent, + VerifiedEvent, finalizeEvent, generateSecretKey, getEventHash, @@ -214,6 +215,12 @@ export const toUnixTimestamp = (date: number | Date) => { export const fromUnixTimestamp = (unix: number) => { return unix * 1000 } +export const randomTimeUpTo2DaysInThePast = (): number => { + const now = Date.now() + const twoDaysInMilliseconds = 2 * 24 * 60 * 60 * 1000 + const randomPastTime = now - Math.floor(Math.random() * twoDaysInMilliseconds) + return toUnixTimestamp(randomPastTime) +} /** * Generate nip44 conversation key @@ -263,19 +270,21 @@ export const countLeadingZeroes = (hex: string) => { /** * Function to create a wrapped event with PoW - * @param event Original event to be wrapped + * @param event Original event to be wrapped (can be unsigned or verified) * @param receiver Public key of the receiver - * @param difficulty PoW difficulty level (default is 20) * @returns */ // -export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { +export const createWrap = ( + event: UnsignedEvent | VerifiedEvent, + receiver: string +) => { // Generate a random secret key and its corresponding public key const randomKey = generateSecretKey() const pubkey = getPublicKey(randomKey) // Encrypt the event content using nip44 encryption - const content = nip44Encrypt(unsignedEvent, randomKey, receiver) + const content = nip44Encrypt(event, randomKey, receiver) // Initialize nonce and leadingZeroes for PoW calculation let nonce = 0 @@ -286,11 +295,12 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { // eslint-disable-next-line no-constant-condition while (true) { // Create an unsigned event with the necessary fields + // TODO: kinds.GiftWrap (wrong kind number in nostr-tools 10/11/2024 at v2.7.2) const event: UnsignedEvent = { kind: 1059, // Event kind content, // Encrypted content pubkey, // Public key of the creator - created_at: unixNow(), // Current timestamp + created_at: randomTimeUpTo2DaysInThePast(), tags: [ // Tags including receiver and nonce ['p', receiver], diff --git a/src/utils/relays.ts b/src/utils/relays.ts index bcb2e98..ef351d9 100644 --- a/src/utils/relays.ts +++ b/src/utils/relays.ts @@ -37,7 +37,6 @@ export const getRelayMapFromNDKRelayList = (ndkRelayList: NDKRelayList) => { export const getDefaultRelayMap = (): RelayMap => ({ [SIGIT_RELAY]: { write: true, read: true } }) - /** * Publishes relay map. * @param relayMap - relay map.