From d0719a30afae42c2e1c0f081666fa780cabddaaa Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sat, 28 Dec 2024 11:18:08 +0200 Subject: [PATCH] batch of updates 1 --- package-lock.json | 291 +++++-- package.json | 5 +- src/App.tsx | 77 +- src/MessageQueueContext.tsx | 182 +++-- src/assets/syncStatus/synced.png | Bin 0 -> 1155 bytes src/assets/syncStatus/synced_minting.png | Bin 0 -> 1168 bytes src/assets/syncStatus/syncing.png | Bin 0 -> 1207 bytes src/atoms/global.ts | 38 +- src/background.ts | 65 +- src/common/ErrorBoundary.tsx | 36 + src/common/useFetchResources.tsx | 168 ++++ src/components/Apps/AppViewerContainer.tsx | 4 +- src/components/Apps/AppsDesktop.tsx | 2 +- src/components/Apps/AppsNavBar.tsx | 9 +- .../Apps/useQortalMessageListener.tsx | 161 +++- src/components/Chat/ChatDirect.tsx | 293 ++++--- src/components/Chat/ChatGroup.tsx | 695 +++++++++++------ src/components/Chat/ChatList.tsx | 445 +++++++---- src/components/Chat/ChatOptions.tsx | 718 +++++++++++++++++ src/components/Chat/MentionList.tsx | 69 ++ src/components/Chat/MessageDisplay.tsx | 130 ++-- src/components/Chat/MessageItem.tsx | 168 +++- src/components/Chat/TipTap.tsx | 141 +++- src/components/Chat/styles.css | 49 ++ src/components/ContextMenuMentions.tsx | 139 ++++ src/components/CoreSyncStatus.css | 59 ++ src/components/CoreSyncStatus.tsx | 113 +++ src/components/Desktop/DesktopFooter.tsx | 5 +- src/components/Embeds/AttachmentEmbed.tsx | 327 ++++++++ src/components/Embeds/Embed-styles.tsx | 18 + src/components/Embeds/Embed.tsx | 406 ++++++++++ src/components/Embeds/ImageEmbed.tsx | 265 +++++++ src/components/Embeds/PollEmbed.tsx | 395 ++++++++++ src/components/Embeds/VideoPlayer.tsx | 723 ++++++++++++++++++ src/components/Embeds/embed-utils.ts | 40 + src/components/Group/Group.tsx | 145 +++- .../Group/ListOfGroupPromotions.tsx | 47 -- src/components/Save/Save.tsx | 692 ++++++++++++++--- src/components/Tutorials/Tutorials.tsx | 108 +++ .../Tutorials/useHandleTutorials.tsx | 169 ++++ src/index.css | 35 +- src/qdn/encryption/group-encryption.ts | 96 ++- src/qortalRequests.ts | 69 +- src/qortalRequests/get.ts | 574 +++++++++++++- src/useQortalGetSaveSettings.tsx | 11 +- src/useRetrieveDataLocalStorage.tsx | 27 +- src/utils/decode.ts | 16 + src/utils/fileReading/index.ts | 12 +- src/utils/generateWallet/generateWallet.ts | 18 + src/utils/memeTypes.ts | 73 +- 50 files changed, 7344 insertions(+), 984 deletions(-) create mode 100644 src/assets/syncStatus/synced.png create mode 100644 src/assets/syncStatus/synced_minting.png create mode 100644 src/assets/syncStatus/syncing.png create mode 100644 src/common/ErrorBoundary.tsx create mode 100644 src/common/useFetchResources.tsx create mode 100644 src/components/Chat/ChatOptions.tsx create mode 100644 src/components/Chat/MentionList.tsx create mode 100644 src/components/ContextMenuMentions.tsx create mode 100644 src/components/CoreSyncStatus.css create mode 100644 src/components/CoreSyncStatus.tsx create mode 100644 src/components/Embeds/AttachmentEmbed.tsx create mode 100644 src/components/Embeds/Embed-styles.tsx create mode 100644 src/components/Embeds/Embed.tsx create mode 100644 src/components/Embeds/ImageEmbed.tsx create mode 100644 src/components/Embeds/PollEmbed.tsx create mode 100644 src/components/Embeds/VideoPlayer.tsx create mode 100644 src/components/Embeds/embed-utils.ts create mode 100644 src/components/Tutorials/Tutorials.tsx create mode 100644 src/components/Tutorials/useHandleTutorials.tsx create mode 100644 src/utils/decode.ts diff --git a/package-lock.json b/package-lock.json index 18b6b78..b2cd9a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@tiptap/extension-color": "^2.5.9", "@tiptap/extension-highlight": "^2.6.6", "@tiptap/extension-image": "^2.6.6", + "@tiptap/extension-mention": "^2.10.4", "@tiptap/extension-placeholder": "^2.6.2", "@tiptap/extension-text-style": "^2.5.9", "@tiptap/extension-underline": "^2.6.6", @@ -37,6 +38,7 @@ "dompurify": "^3.1.6", "emoji-picker-react": "^4.12.0", "file-saver": "^2.0.5", + "html-to-text": "^9.0.5", "jssha": "3.3.1", "lodash": "^4.17.21", "mime": "^4.0.4", @@ -60,7 +62,8 @@ "short-unique-id": "^5.2.0", "slate": "^0.103.0", "slate-react": "^0.109.0", - "tiptap-extension-resize-image": "^1.1.8" + "tiptap-extension-resize-image": "^1.1.8", + "ts-key-enum": "^2.0.12" }, "devDependencies": { "@testing-library/dom": "^10.3.0", @@ -1721,9 +1724,9 @@ } }, "node_modules/@remirror/core-constants": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-2.0.2.tgz", - "integrity": "sha512-dyHY+sMF0ihPus3O27ODd4+agdHMEmuRdyiZJ2CCWjPV5UFmn17ZbElvk6WOGVE4rdCJKZQCrPV2BcikOMLUGQ==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==" }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.13.0", @@ -1894,6 +1897,18 @@ "win32" ] }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2207,15 +2222,15 @@ } }, "node_modules/@tiptap/core": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.6.6.tgz", - "integrity": "sha512-VO5qTsjt6rwworkuo0s5AqYMfDA0ZwiTiH6FHKFSu2G/6sS7HKcc/LjPq+5Legzps4QYdBDl3W28wGsGuS1GdQ==", + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.10.4.tgz", + "integrity": "sha512-fExFRTRgb6MSpg2VvR5qO2dPTQAZWuUoU4UsBCurIVcPWcyVv4FG1YzgMyoLDKy44rebFtwUGJbfU9NzX7Q/bA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^2.6.6" + "@tiptap/pm": "^2.7.0" } }, "node_modules/@tiptap/extension-blockquote": { @@ -2460,6 +2475,20 @@ "@tiptap/core": "^2.5.9" } }, + "node_modules/@tiptap/extension-mention": { + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-2.10.4.tgz", + "integrity": "sha512-pVouKWxSVQSy4zn6HrljPIP1AG826gkm/w18Asi8QnZvR0AMqGLh9q7qd9Kc0j8NKoCzlzK8hECGlYPEaBldow==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "@tiptap/suggestion": "^2.7.0" + } + }, "node_modules/@tiptap/extension-ordered-list": { "version": "2.5.9", "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.5.9.tgz", @@ -2546,28 +2575,28 @@ } }, "node_modules/@tiptap/pm": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.6.6.tgz", - "integrity": "sha512-56FGLPn3fwwUlIbLs+BO21bYfyqP9fKyZQbQyY0zWwA/AG2kOwoXaRn7FOVbjP6CylyWpFJnpRRmgn694QKHEg==", + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.10.4.tgz", + "integrity": "sha512-pZ4NEkRtYoDLe0spARvXZ1N3hNv/5u6vfPdPtEbmNpoOSjSNqDC1kVM+qJY0iaCYpxbxcv7cxn3kBumcFLQpJQ==", "dependencies": { "prosemirror-changeset": "^2.2.1", "prosemirror-collab": "^1.3.1", - "prosemirror-commands": "^1.5.2", + "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.2", - "prosemirror-markdown": "^1.13.0", + "prosemirror-markdown": "^1.13.1", "prosemirror-menu": "^1.2.4", - "prosemirror-model": "^1.22.2", + "prosemirror-model": "^1.23.0", "prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-list": "^1.4.1", "prosemirror-state": "^1.4.3", - "prosemirror-tables": "^1.4.0", - "prosemirror-trailing-node": "^2.0.9", - "prosemirror-transform": "^1.9.0", - "prosemirror-view": "^1.33.9" + "prosemirror-tables": "^1.6.1", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" }, "funding": { "type": "github", @@ -2625,6 +2654,20 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/suggestion": { + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.10.4.tgz", + "integrity": "sha512-7Bzcn1REA7OmVRxiMF2kVK9EhosXotdLAGaEvSbn4zQtHCJG0tREuYvPy53LGzVuPkBDR6Pf6sp1QbGvSne/8g==", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2720,12 +2763,31 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==" + }, "node_modules/@types/lodash": { "version": "4.17.7", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", "dev": true }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -4084,6 +4146,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -4198,11 +4268,62 @@ "csstype": "^3.0.2" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz", "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==" }, + "node_modules/domutils": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.1.tgz", + "integrity": "sha512-xWXmuRnN9OMP6ptPd2+H0cCbcYBULa5YDTbMm/2lvkWvNA3O4wcW+GvzooqBuNM8yy6pl3VIAeJTUUWUbfI5Fw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.710", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.710.tgz", @@ -5189,6 +5310,39 @@ "node": ">=18" } }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5840,6 +5994,14 @@ "json-buffer": "3.0.1" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -8795,6 +8957,18 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8852,6 +9026,14 @@ "node": "*" } }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -9010,13 +9192,13 @@ } }, "node_modules/prosemirror-commands": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.6.0.tgz", - "integrity": "sha512-xn1U/g36OqXn2tn5nGmvnnimAj/g1pUx2ypJJIe8WkVX83WyJVC5LTARaxZa2AtQRwntu9Jc5zXs9gL9svp/mg==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.6.2.tgz", + "integrity": "sha512-0nDHH++qcf/BuPLYvmqZTUUsPJUCPBUXt0J1ErTcDIS369CTp773itzLGIgIXG4LJXOlwYCr44+Mh4ii6MP1QA==", "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", - "prosemirror-transform": "^1.0.0" + "prosemirror-transform": "^1.10.2" } }, "node_modules/prosemirror-dropcursor": { @@ -9070,10 +9252,11 @@ } }, "node_modules/prosemirror-markdown": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.0.tgz", - "integrity": "sha512-UziddX3ZYSYibgx8042hfGKmukq5Aljp2qoBiJRejD/8MH70siQNz5RB1TrdTPheqLMy4aCe4GYNF10/3lQS5g==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.1.tgz", + "integrity": "sha512-Sl+oMfMtAjWtlcZoj/5L/Q39MpEnVZ840Xo330WJWUvgyhNmLBLN7MsHn07s53nG/KImevWHSE6fEj4q/GihHw==", "dependencies": { + "@types/markdown-it": "^14.0.0", "markdown-it": "^14.0.0", "prosemirror-model": "^1.20.0" } @@ -9090,9 +9273,9 @@ } }, "node_modules/prosemirror-model": { - "version": "1.22.3", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.22.3.tgz", - "integrity": "sha512-V4XCysitErI+i0rKFILGt/xClnFJaohe/wrrlT2NSZ+zk8ggQfDH4x2wNK7Gm0Hp4CIoWizvXFP7L9KMaCuI0Q==", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.24.1.tgz", + "integrity": "sha512-YM053N+vTThzlWJ/AtPtF1j0ebO36nvbmDy4U7qA2XQB8JVaQp1FmB9Jhrps8s+z+uxhhVTny4m20ptUvhk0Mg==", "dependencies": { "orderedmap": "^2.0.0" } @@ -9126,23 +9309,23 @@ } }, "node_modules/prosemirror-tables": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.4.0.tgz", - "integrity": "sha512-fxryZZkQG12fSCNuZDrYx6Xvo2rLYZTbKLRd8rglOPgNJGMKIS8uvTt6gGC38m7UCu/ENnXIP9pEz5uDaPc+cA==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.6.2.tgz", + "integrity": "sha512-97dKocVLrEVTQjZ4GBLdrrMw7Gv3no8H8yMwf5IRM9OoHrzbWpcH5jJxYgNQIRCtdIqwDctT1HdMHrGTiwp1dQ==", "dependencies": { - "prosemirror-keymap": "^1.1.2", - "prosemirror-model": "^1.8.1", - "prosemirror-state": "^1.3.1", - "prosemirror-transform": "^1.2.1", - "prosemirror-view": "^1.13.3" + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.24.1", + "prosemirror-state": "^1.4.3", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.1" } }, "node_modules/prosemirror-trailing-node": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-2.0.9.tgz", - "integrity": "sha512-YvyIn3/UaLFlFKrlJB6cObvUhmwFNZVhy1Q8OpW/avoTbD/Y7H5EcjK4AZFKhmuS6/N6WkGgt7gWtBWDnmFvHg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", "dependencies": { - "@remirror/core-constants": "^2.0.2", + "@remirror/core-constants": "3.0.0", "escape-string-regexp": "^4.0.0" }, "peerDependencies": { @@ -9163,17 +9346,17 @@ } }, "node_modules/prosemirror-transform": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.9.0.tgz", - "integrity": "sha512-5UXkr1LIRx3jmpXXNKDhv8OyAOeLTGuXNwdVfg8x27uASna/wQkr9p6fD3eupGOi4PLJfbezxTyi/7fSJypXHg==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.2.tgz", + "integrity": "sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==", "dependencies": { "prosemirror-model": "^1.21.0" } }, "node_modules/prosemirror-view": { - "version": "1.33.9", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.33.9.tgz", - "integrity": "sha512-xV1A0Vz9cIcEnwmMhKKFAOkfIp8XmJRnaZoPqNXrPS7EK5n11Ov8V76KhR0RsfQd/SIzmWY+bg+M44A2Lx/Nnw==", + "version": "1.37.1", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.37.1.tgz", + "integrity": "sha512-MEAnjOdXU1InxEmhjgmEzQAikaS6lF3hD64MveTPpjOGNTl87iRLA1HupC/DEV6YuK7m4Q9DHFNTjwIVtqz5NA==", "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -9942,6 +10125,17 @@ "compute-scroll-into-view": "^3.0.2" } }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", @@ -10456,6 +10650,11 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-key-enum": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/ts-key-enum/-/ts-key-enum-2.0.13.tgz", + "integrity": "sha512-zixs6j8+NhzazLUQ1SiFrlo1EFWG/DbqLuUGcWWZ5zhwjRT7kbi1hBlofxdqel+h28zrby2It5TrOyKp04kvqw==" + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", diff --git a/package.json b/package.json index 7335cc8..1ba83b0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@tiptap/extension-color": "^2.5.9", "@tiptap/extension-highlight": "^2.6.6", "@tiptap/extension-image": "^2.6.6", + "@tiptap/extension-mention": "^2.10.4", "@tiptap/extension-placeholder": "^2.6.2", "@tiptap/extension-text-style": "^2.5.9", "@tiptap/extension-underline": "^2.6.6", @@ -41,6 +42,7 @@ "dompurify": "^3.1.6", "emoji-picker-react": "^4.12.0", "file-saver": "^2.0.5", + "html-to-text": "^9.0.5", "jssha": "3.3.1", "lodash": "^4.17.21", "mime": "^4.0.4", @@ -64,7 +66,8 @@ "short-unique-id": "^5.2.0", "slate": "^0.103.0", "slate-react": "^0.109.0", - "tiptap-extension-resize-image": "^1.1.8" + "tiptap-extension-resize-image": "^1.1.8", + "ts-key-enum": "^2.0.12" }, "devDependencies": { "@testing-library/dom": "^10.3.0", diff --git a/src/App.tsx b/src/App.tsx index 803b1cb..b127506 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -103,9 +103,13 @@ import { MainAvatar } from "./components/MainAvatar"; import { useRetrieveDataLocalStorage } from "./useRetrieveDataLocalStorage"; import { useQortalGetSaveSettings } from "./useQortalGetSaveSettings"; import { useRecoilState, useResetRecoilState } from "recoil"; -import { canSaveSettingToQdnAtom, fullScreenAtom, hasSettingsChangedAtom, oldPinnedAppsAtom, settingsLocalLastUpdatedAtom, settingsQDNLastUpdatedAtom, sortablePinnedAppsAtom } from "./atoms/global"; +import { canSaveSettingToQdnAtom, fullScreenAtom, hasSettingsChangedAtom, isUsingImportExportSettingsAtom, oldPinnedAppsAtom, settingsLocalLastUpdatedAtom, settingsQDNLastUpdatedAtom, sortablePinnedAppsAtom } from "./atoms/global"; import { useAppFullScreen } from "./useAppFullscreen"; import { NotAuthenticated } from "./ExtStates/NotAuthenticated"; +import { useFetchResources } from "./common/useFetchResources"; +import { Tutorials } from "./components/Tutorials/Tutorials"; +import { useHandleTutorials } from "./components/Tutorials/useHandleTutorials"; +import { CoreSyncStatus } from "./components/CoreSyncStatus"; type extStates = | "not-authenticated" @@ -217,8 +221,12 @@ export const resumeAllQueues = () => { payload: {}, }); }; - +const defaultValuesGlobal = { + openTutorialModal: null, + setOpenTutorialModal: ()=> {} +} export const MyContext = createContext(defaultValues); +export const GlobalContext = createContext(defaultValuesGlobal); export let globalApiKey: string | null = null; @@ -308,6 +316,7 @@ function App() { const isFocusedRef = useRef(true); const { isShow, onCancel, onOk, show, message } = useModal(); const { isShow: isShowUnsavedChanges, onCancel: onCancelUnsavedChanges, onOk: onOkUnsavedChanges, show: showUnsavedChanges, message: messageUnsavedChanges } = useModal(); + const {downloadResource} = useFetchResources() const { onCancel: onCancelQortalRequest, @@ -339,10 +348,11 @@ function App() { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const qortalRequestCheckbox1Ref = useRef(null); useRetrieveDataLocalStorage() - useQortalGetSaveSettings(userInfo?.name) + useQortalGetSaveSettings(userInfo?.name, extState === "authenticated") const [fullScreen, setFullScreen] = useRecoilState(fullScreenAtom); - + const resetAtomIsUsingImportExportSettingsAtom = useResetRecoilState(isUsingImportExportSettingsAtom) const { toggleFullScreen } = useAppFullScreen(setFullScreen); + const {showTutorial, openTutorialModal, shownTutorialsInitiated, setOpenTutorialModal} = useHandleTutorials() useEffect(() => { // Attach a global event listener for double-click @@ -371,6 +381,7 @@ function App() { resetAtomSettingsQDNLastUpdatedAtom(); resetAtomSettingsLocalLastUpdatedAtom(); resetAtomOldPinnedAppsAtom(); + resetAtomIsUsingImportExportSettingsAtom() }; useEffect(() => { if (!isMobile) return; @@ -1472,18 +1483,20 @@ function App() { Get QORT at Q-Trade - - - { - setExtstate("download-wallet"); - setIsOpenDrawerProfile(false); + + + > + + {!isMobile && ( <> @@ -1539,6 +1552,29 @@ function App() { }} /> )} + + + + + { + setExtstate("download-wallet"); + setIsOpenDrawerProfile(false); + }} + src={Download} + style={{ + cursor: "pointer", + }} + /> + + ); @@ -1554,7 +1590,14 @@ function App() { backgroundRepeat: desktopViewMode === 'apps' && 'no-repeat', }} > - + + + {extState === "not-authenticated" && ( )} @@ -1574,6 +1617,7 @@ function App() { show, message, rootHeight, + downloadResource }} > {renderProfile()} + ); } diff --git a/src/MessageQueueContext.tsx b/src/MessageQueueContext.tsx index 67841de..7104520 100644 --- a/src/MessageQueueContext.tsx +++ b/src/MessageQueueContext.tsx @@ -6,18 +6,20 @@ const MessageQueueContext = createContext(null); export const useMessageQueue = () => useContext(MessageQueueContext); const uid = new ShortUniqueId({ length: 8 }); -let messageQueue = []; // Global message queue export const MessageQueueProvider = ({ children }) => { + const messageQueueRef = useRef([]); const [queueChats, setQueueChats] = useState({}); // Stores chats and status for display - const isProcessingRef = useRef(false); // To track if the queue is being processed - const maxRetries = 3; + const maxRetries = 2; + const clearStatesMessageQueueProvider = useCallback(() => { setQueueChats({}); - messageQueue = []; - isProcessingRef.current = false; + messageQueueRef.current = []; }, []); + // Promise-based lock to prevent concurrent executions + const processingPromiseRef = useRef(Promise.resolve()); + // Function to add a message to the queue const addToQueue = useCallback((sendMessageFunc, messageObj, type, groupDirectId) => { const tempId = uid.rnd(); @@ -25,6 +27,7 @@ export const MessageQueueProvider = ({ children }) => { ...messageObj, type, groupDirectId, + signature: uid.rnd(), identifier: tempId, retries: 0, // Retry count for display purposes status: 'pending' // Initial status is 'pending' @@ -36,60 +39,38 @@ export const MessageQueueProvider = ({ children }) => { [groupDirectId]: [...(prev[groupDirectId] || []), chatData] })); - // Add the message to the global messageQueue - messageQueue = [ - ...messageQueue, - { func: sendMessageFunc, identifier: tempId, groupDirectId, specialId: messageObj?.message?.specialId } - ]; + // Add the message to the global messageQueueRef + messageQueueRef.current.push({ + func: sendMessageFunc, + identifier: tempId, + groupDirectId, + specialId: messageObj?.message?.specialId + }); - // Start processing the queue if not already processing + // Start processing the queue processQueue([], groupDirectId); - }, []); - // Method to process with new messages and groupDirectId - const processWithNewMessages = (newMessages, groupDirectId) => { - processQueue(newMessages, groupDirectId); - }; + // Function to process the message queue + const processQueue = useCallback((newMessages = [], groupDirectId) => { - // Function to process the messageQueue and handle new messages - const processQueue = useCallback(async (newMessages = [], groupDirectId) => { - // Filter out any message in the queue that matches the specialId from newMessages - messageQueue = messageQueue.filter((msg) => { - return !newMessages.some(newMsg => newMsg?.specialId === msg?.specialId); - }); + processingPromiseRef.current = processingPromiseRef.current + .then(() => processQueueInternal(newMessages, groupDirectId)) + .catch((err) => console.error('Error in processQueue:', err)); + }, []); - // Remove any corresponding entries in queueChats for the provided groupDirectId - setQueueChats((prev) => { - const updatedChats = { ...prev }; - if (updatedChats[groupDirectId]) { - // Remove any message in queueChats that has a matching specialId - updatedChats[groupDirectId] = updatedChats[groupDirectId].filter((chat) => { - - return !newMessages.some(newMsg => newMsg?.specialId === chat?.message?.specialId); - }); + // Internal function to handle queue processing + const processQueueInternal = async (newMessages, groupDirectId) => { + // Remove any messages from the queue that match the specialId from newMessages + + // If the queue is empty, no need to process + if (messageQueueRef.current.length === 0) return; - updatedChats[groupDirectId] = updatedChats[groupDirectId].filter((chat) => { - return chat?.status !== 'failed-permanent' - }); - - // If no more chats for this group, delete the groupDirectId entry - if (updatedChats[groupDirectId].length === 0) { - delete updatedChats[groupDirectId]; - } - } - return updatedChats; - }); - - // If currently processing or the queue is empty, return - if (isProcessingRef.current || messageQueue.length === 0) return; - - isProcessingRef.current = true; // Lock the queue for processing - - while (messageQueue.length > 0) { - const currentMessage = messageQueue[0]; // Get the first message in the queue + // Process messages sequentially + while (messageQueueRef.current.length > 0) { + const currentMessage = messageQueueRef.current[0]; // Get the first message in the queue const { groupDirectId, identifier } = currentMessage; - + // Update the chat status to 'sending' setQueueChats((prev) => { const updatedChats = { ...prev }; @@ -103,25 +84,18 @@ export const MessageQueueProvider = ({ children }) => { } return updatedChats; }); - + try { - // Execute the function stored in the messageQueue + // Execute the function stored in the messageQueueRef + await currentMessage.func(); - - // Remove the message from the messageQueue after successful sending - messageQueue = messageQueue.slice(1); // Slice here remains for successful messages - - // Remove the message from queueChats after success - // setQueueChats((prev) => { - // const updatedChats = { ...prev }; - // updatedChats[groupDirectId] = updatedChats[groupDirectId].filter( - // (item) => item.identifier !== identifier - // ); - // return updatedChats; - // }); + + // Remove the message from the queue after successful sending + messageQueueRef.current.shift(); + } catch (error) { console.error('Message sending failed', error); - + // Retry logic setQueueChats((prev) => { const updatedChats = { ...prev }; @@ -137,28 +111,76 @@ export const MessageQueueProvider = ({ children }) => { } else { // Max retries reached, set status to 'failed-permanent' updatedChats[groupDirectId][chatIndex].status = 'failed-permanent'; - - // Remove the message from the messageQueue after max retries - messageQueue = messageQueue.slice(1); // Slice for failed messages after max retries - - // Remove the message from queueChats after failure - // updatedChats[groupDirectId] = updatedChats[groupDirectId].filter( - // (item) => item.identifier !== identifier - // ); + + // Remove the message from the queue after max retries + messageQueueRef.current.shift(); } } return updatedChats; }); } - - // Delay between processing each message to avoid overlap - await new Promise((res) => setTimeout(res, 5000)); + + // Optional delay between processing messages + // await new Promise((res) => setTimeout(res, 5000)); } + }; + + // Method to process with new messages and groupDirectId + const processWithNewMessages = (newMessages, groupDirectId) => { + let updatedNewMessages = newMessages + if (newMessages.length > 0) { + // Remove corresponding entries in queueChats for the provided groupDirectId + setQueueChats((prev) => { + const updatedChats = { ...prev }; + if (updatedChats[groupDirectId]) { - // Reset the processing lock once all messages are processed - isProcessingRef.current = false; - }, []); + updatedNewMessages = newMessages?.map((msg)=> { + const findTempMsg = updatedChats[groupDirectId]?.find((msg2)=> msg2?.message?.specialId === msg?.specialId) + if(findTempMsg){ + return { + ...msg, + tempSignature: findTempMsg?.signature + } + } + return msg + }) + + + updatedChats[groupDirectId] = updatedChats[groupDirectId].filter((chat) => { + return !newMessages.some(newMsg => newMsg?.specialId === chat?.message?.specialId); + }); + + // Remove messages with status 'failed-permanent' + updatedChats[groupDirectId] = updatedChats[groupDirectId].filter((chat) => { + return chat?.status !== 'failed-permanent'; + }); + + // If no more chats for this group, delete the groupDirectId entry + if (updatedChats[groupDirectId].length === 0) { + delete updatedChats[groupDirectId]; + } + } + return updatedChats; + }); + + } + setTimeout(() => { + if(!messageQueueRef.current.find((msg) => msg?.groupDirectId === groupDirectId)){ + setQueueChats((prev) => { + const updatedChats = { ...prev }; + if (updatedChats[groupDirectId]) { + delete updatedChats[groupDirectId] + } + return updatedChats + } + ) + } + }, 300); + + return updatedNewMessages + }; + return ( {children} diff --git a/src/assets/syncStatus/synced.png b/src/assets/syncStatus/synced.png new file mode 100644 index 0000000000000000000000000000000000000000..f944bad97047ff299964b194401bc61e9a105ed9 GIT binary patch literal 1155 zcmWlXdobOl}#87@C<- zn{{VgW?H58tR$k#W=SQ+C6{&NG&V8}GifxQ|BY2eSV`?R(gGqG$r!TyQiIg%d>E>|w-hMXSaLWvY{^|&NK+FkG@I8%(WIpp*rvJ4rWU{)Z$8WE*Pxr?v@aPAX8bTo8u zcDTZJA1oZf8^8%J@z9?|eCK37qAT%JIYG(J8)6NFO8g=wL^>QsU^0$n!ZidUHBwqY z^&uGq3xr6V1TsRaVmpMLDC^fHrE^gXrKIa!DEuHzGarjTOEK@C! zXiq;<$~82_J-sENkR1A9<#n$@s@8mJQMpjfdh|2YU$-jkFUvn`-cny@U{s;lTp2pD zyapf@_<4IV&XUpwGOwPZf-@zVA8n2qYi?|6zNO3Wd)tn)=Cx+4>+Q%AWrHa}h3^$X>T@(`%$OwGhOSOJ=?Cm5OlY9MLu{KEPN>%*su^+I5q7ARY&U9o`*X-m9jP4PONN+;4s|T zWrF45=UEJ9r@tk8t~=NG!WYNx8ad7mwO_GpS?SDtS$Kd*h$M4hyEay4cwsp<41Tjuz``wg4YDZ64P%O@xqViSF16D$3Dj|X+j06(7q K?;HD==l=(8gzk?3 literal 0 HcmV?d00001 diff --git a/src/assets/syncStatus/synced_minting.png b/src/assets/syncStatus/synced_minting.png new file mode 100644 index 0000000000000000000000000000000000000000..567e784ba8520504f89d08bc108e9a13ba21e6a3 GIT binary patch literal 1168 zcmW-fc~BEp5XQdOI%Mdo8%9Y9nKqU%txj*Iok>_^!L<-Fo<+B2NVg^3-~`*3><;R3Lw$}!Rr#5aOj zfx-#oj^Z;5?6b$Mmk2FElnAHuAb5w|zW_M`(vbZpEZmf@oy-D%4oHHN6X+L_+70eB zZafJ&cN%AZj!a-GJ3-kH*U0Ec$Zdoa;>TiK zZbecDqRJ61MtqY}w!*K%sY0`v3yiJ*$oCqR@CR!m@NJe7%k?49A{45m;z*Efe-$S4 zbI!rY8X0{!Ux~9^m{VUq6UzpL;J!gh*UrPP;LqdK&5FD*T$3P-kK;^TJ9j07`1>$* zfm1;qiT&TtPEa#;wtqU7&-ruPmV z3HLedP;qH07;)0tC>*B4@_0ymEROiXiU!hQGkV~VC<4~rW5Xh5Vyqt3|3O=+Oj>5j72Z{+;=lG8|vCFZ=8gSGYf014PoWlEN8&dc0Qf3;Tf#6F|$dP z-X%G*-n4$yKrN@2)Va1`Q8w48zpF|t78?^6BDoso0HMH>>g*Rw;Pj^CggV1*FnF{v z{FAPE*;iq~VT$8uPt5PpwOZ`D=z!&k53PL5=EvL0`(dMyN`h? zp*-t99=PuEM)lzCuK8Q!J&!jB&4yV2-6>h7*xy+#d;yZ<3&F^5`}IZjpMNxWZgMo4 zLlPbk7+HG9jI}oqbdDM61@ts*YUXFvB=%J{5=y)CDeB`psgYG3xnJZpN$%{Rq>_1D zw*66Wjq_K#{JT}IlxXJJG>la+Jak-HZ5$o1>TTPd{CPiBr{A*Lo&RdPrjY2Zdytiq vZbD@h33RW^bYE>{yy(j?xx7Vf)XOHlHEqviHs literal 0 HcmV?d00001 diff --git a/src/assets/syncStatus/syncing.png b/src/assets/syncStatus/syncing.png new file mode 100644 index 0000000000000000000000000000000000000000..82d39bbb40cfdfb51c2eb15802e129a3d9ca79ed GIT binary patch literal 1207 zcmV;o1W5adP)eLaF?E5ZbvyuLOf4rXNWp9MT#~)D^-9(M`o-qFnUg4yIEhuMMgkC zU|eH>xm;saWradlb)-vCbv;RjOj|o*cCJ}sfH_1wOKFfxR$pq0uU%+YZH<^hOxjvn z#YIK0FE0QmIshy}03$TQMMiHjJ#{-nOfNidFgSHTN@6)k03tL1COHKtJK0)V04_wV zF*3hKM*u59qcJlZD?LIkJ+?$fy;N7JFfjltKoToHW;;f!Lq@X1UpYTi3NS_hBsKsfHO*OD05VJ1T3W10PM$nM4>CxiI6bRW zTR}QXA1^-$CN~r-Jjq&J&{$d3T3Y}iF^^AKfJ|4VQ(B=^UR*v(8#hh5MMr#5U~586 z5;RE-Cpd~!VPHm8o=sN6Szf(YTb4;w#6?B6R$HzwFQ80RyIEhYFfp`PUzbKuZ(Sg{<0 zZq{@Np?T_@5Oc4AgCT3~f&oEhMeqL5olvUqn# z@9d$Y8_OL4A!Wl2__ey0QXUYJ@#HRSZ0^J^fY7lAdDKx(aa{!?uO1!6*b5+a68D$> zD)|XvVO0&Wq*zqg4^WzzoeWv||9=w_1W?_dQZt09Wtn;s0w9!@^)DUcFaRkrjvo}Q ztmMIoHRpnW&%=$}w~v>U`0UGDoN?rFe{khB=jhp&7w6f*xYQ4z%NK5P-f8^-LhrBL z1>C}CV4_G!0dAj)Y&TWj{VC{O~Xh^@FZo&Qo4JsgSK5;K5q002ovPDHLkV1j}H@I(Lr literal 0 HcmV?d00001 diff --git a/src/atoms/global.ts b/src/atoms/global.ts index aa4d16a..23e2b84 100644 --- a/src/atoms/global.ts +++ b/src/atoms/global.ts @@ -1,4 +1,4 @@ -import { atom } from 'recoil'; +import { atom, selectorFamily } from 'recoil'; export const sortablePinnedAppsAtom = atom({ @@ -88,4 +88,40 @@ export const promotionTimeIntervalAtom = atom({ export const promotionsAtom = atom({ key: 'promotionsAtom', default: [], +}); + +export const resourceDownloadControllerAtom = atom({ + key: 'resourceDownloadControllerAtom', + default: {}, +}); + +export const resourceKeySelector = selectorFamily({ + key: 'resourceKeySelector', + get: (key) => ({ get }) => { + const resources = get(resourceDownloadControllerAtom); + return resources[key] || null; // Return the value for the key or null if not found + }, +}); + +export const blobControllerAtom = atom({ + key: 'blobControllerAtom', + default: {}, +}); + +export const blobKeySelector = selectorFamily({ + key: 'blobKeySelector', + get: (key) => ({ get }) => { + const blobs = get(blobControllerAtom); + return blobs[key] || null; // Return the value for the key or null if not found + }, +}); + +export const selectedGroupIdAtom = atom({ + key: 'selectedGroupIdAtom', + default: null, +}); + +export const isUsingImportExportSettingsAtom = atom({ + key: 'isUsingImportExportSettingsAtom', + default: null, }); \ No newline at end of file diff --git a/src/background.ts b/src/background.ts index fd789f2..d61d559 100644 --- a/src/background.ts +++ b/src/background.ts @@ -44,6 +44,9 @@ export function getProtocol(url) { } } +export let groupSecretkeys = {} + + export const gateways = ['ext-node.qortal.link'] @@ -154,7 +157,7 @@ const getCustomNodesFromStorage = async () => { // return `/arbitrary/resources/searchsimple`; // } // } -const getArbitraryEndpoint = async () => { +export const getArbitraryEndpoint = async () => { const apiKey = await getApiKeyFromStorage(); // Retrieve apiKey asynchronously if (apiKey) { return `/arbitrary/resources/searchsimple`; @@ -3006,6 +3009,41 @@ async function addTimestampGroupAnnouncement({ }); } +async function getTimestampMention() { + const wallet = await getSaveWallet(); + const address = wallet.address0; + const key = `enter-mention-timestamp-${address}`; + const res = await chrome.storage.local.get([key]); + if (res?.[key]) { + const parsedData = JSON.parse(res[key]); + return parsedData; + } else { + return {}; + } +} + + +export async function addTimestampMention({ groupId, timestamp }) { + const wallet = await getSaveWallet(); + const address = wallet.address0; + const data = await getTimestampMention(); + data[groupId] = timestamp; + const dataString = JSON.stringify(data); + + return await new Promise((resolve, reject) => { + chrome.storage.local.set( + { [`enter-mention-timestamp-${address}`]: dataString }, + () => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(true); + } + } + ); + }); +} + async function getGroupData() { const wallet = await getSaveWallet(); const address = wallet.address0; @@ -3459,6 +3497,29 @@ chrome?.runtime?.onMessage.addListener((request, sender, sendResponse) => { } break; + case "addTimestampMention": { + const { groupId, timestamp } = request.payload; + addTimestampMention({ groupId, timestamp }) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + console.error(error.message); + }); + } + break; + case "getTimestampMention": { + getTimestampMention() + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + console.error(error.message); + }); + } + break; case "makeAdmin": { const { groupId, qortalAddress } = request.payload; @@ -4508,7 +4569,7 @@ chrome?.runtime?.onMessage.addListener((request, sender, sendResponse) => { // for announcement notification clearInterval(interval); } - + groupSecretkeys = {} const wallet = await getSaveWallet(); const address = wallet.address0; const key1 = `tempPublish-${address}`; diff --git a/src/common/ErrorBoundary.tsx b/src/common/ErrorBoundary.tsx new file mode 100644 index 0000000..58b3185 --- /dev/null +++ b/src/common/ErrorBoundary.tsx @@ -0,0 +1,36 @@ +import React, { ReactNode } from 'react' + +interface ErrorBoundaryProps { + children: ReactNode + fallback: ReactNode +} + +interface ErrorBoundaryState { + hasError: boolean +} + +class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { + hasError: false + } + + static getDerivedStateFromError(_: Error): ErrorBoundaryState { + return { hasError: true } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + // You can log the error and errorInfo here, for example, to an error reporting service. + console.error('Error caught in ErrorBoundary:', error, errorInfo) + } + + render(): React.ReactNode { + if (this.state.hasError) return this.props.fallback + + return this.props.children + } +} + +export default ErrorBoundary diff --git a/src/common/useFetchResources.tsx b/src/common/useFetchResources.tsx new file mode 100644 index 0000000..1a4cbd8 --- /dev/null +++ b/src/common/useFetchResources.tsx @@ -0,0 +1,168 @@ +import React, { useCallback, useRef } from 'react'; +import { useRecoilState } from 'recoil'; +import { resourceDownloadControllerAtom } from '../atoms/global'; +import { getBaseApiReact } from '../App'; + +export const useFetchResources = () => { + const [resources, setResources] = useRecoilState(resourceDownloadControllerAtom); + + const downloadResource = useCallback(({ service, name, identifier }, build) => { + setResources((prev) => ({ + ...prev, + [`${service}-${name}-${identifier}`]: { + ...(prev[`${service}-${name}-${identifier}`] || {}), + service, + name, + identifier, + }, + })); + + try { + let isCalling = false; + let percentLoaded = 0; + let timer = 24; + let tries = 0; + let calledFirstTime = false + let intervalId + let timeoutId + const callFunction = async ()=> { + if (isCalling) return; + isCalling = true; + + + + let res + + if(!build){ + const urlFirstTime = `${getBaseApiReact()}/arbitrary/resource/status/${service}/${name}/${identifier}`; + const resCall = await fetch(urlFirstTime, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + res = await resCall.json() + if(tries > 18 ){ + if(intervalId){ + clearInterval(intervalId) + } + if(timeoutId){ + clearTimeout(timeoutId) + } + setResources((prev) => ({ + ...prev, + [`${service}-${name}-${identifier}`]: { + ...(prev[`${service}-${name}-${identifier}`] || {}), + status: { + ...res, + status: 'FAILED_TO_DOWNLOAD', + }, + }, + })); + return + } + tries = tries + 1 + + } + + + if(build || (calledFirstTime === false && res?.status !== 'READY')){ + const url = `${getBaseApiReact()}/arbitrary/resource/properties/${service}/${name}/${identifier}?build=true`; + const resCall = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + res = await resCall.json(); + + } + calledFirstTime = true + isCalling = false; + + if (res.localChunkCount) { + if (res.percentLoaded) { + if (res.percentLoaded === percentLoaded && res.percentLoaded !== 100) { + timer = timer - 5; + } else { + timer = 24; + } + + if (timer < 0) { + timer = 24; + isCalling = true; + + // Update Recoil state for refetching + setResources((prev) => ({ + ...prev, + [`${service}-${name}-${identifier}`]: { + ...(prev[`${service}-${name}-${identifier}`] || {}), + status: { + ...res, + status: 'REFETCHING', + }, + }, + })); + + timeoutId = setTimeout(() => { + isCalling = false; + downloadResource({ name, service, identifier }, true); + }, 25000); + + return; + } + + percentLoaded = res.percentLoaded; + } + + // Update Recoil state for progress + setResources((prev) => ({ + ...prev, + [`${service}-${name}-${identifier}`]: { + ...(prev[`${service}-${name}-${identifier}`] || {}), + status: res, + }, + })); + } + + // Check if progress is 100% and clear interval if true + if (res?.status === 'READY') { + if(intervalId){ + clearInterval(intervalId); + + } + if(timeoutId){ + clearTimeout(timeoutId) + } + // Update Recoil state for completion + setResources((prev) => ({ + ...prev, + [`${service}-${name}-${identifier}`]: { + ...(prev[`${service}-${name}-${identifier}`] || {}), + status: res, + }, + })); + } + if(res?.status === 'DOWNLOADED'){ + const url = `${getBaseApiReact()}/arbitrary/resource/status/${service}/${name}/${identifier}?build=true`; + const resCall = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + res = await resCall.json(); + } + } + callFunction() + intervalId = setInterval(async () => { + callFunction() + }, 5000); + + } catch (error) { + console.error('Error during resource fetch:', error); + } + }, [setResources]); + + return { downloadResource }; +}; diff --git a/src/components/Apps/AppViewerContainer.tsx b/src/components/Apps/AppViewerContainer.tsx index 51bc0ff..8622eca 100644 --- a/src/components/Apps/AppViewerContainer.tsx +++ b/src/components/Apps/AppViewerContainer.tsx @@ -3,7 +3,7 @@ import { AppViewer } from './AppViewer'; import Frame from 'react-frame-component'; import { MyContext, isMobile } from '../../App'; -const AppViewerContainer = React.forwardRef(({ app, isSelected, hide }, ref) => { +const AppViewerContainer = React.forwardRef(({ app, isSelected, hide, customHeight }, ref) => { const { rootHeight } = useContext(MyContext); @@ -36,7 +36,7 @@ const AppViewerContainer = React.forwardRef(({ app, isSelected, hide }, ref) => } style={{ display: (!isSelected || hide) && 'none', - height: !isMobile ? '100vh' : `calc(${rootHeight} - 60px - 45px)`, + height: customHeight ? customHeight : !isMobile ? '100vh' : `calc(${rootHeight} - 60px - 45px)`, border: 'none', width: '100%', overflow: 'hidden', diff --git a/src/components/Apps/AppsDesktop.tsx b/src/components/Apps/AppsDesktop.tsx index ab4eba9..6d84ba7 100644 --- a/src/components/Apps/AppsDesktop.tsx +++ b/src/components/Apps/AppsDesktop.tsx @@ -366,7 +366,7 @@ export const AppsDesktop = ({ mode, setMode, show , myName, goToHome, setDesktop /> - + {mode !== 'home' && ( diff --git a/src/components/Apps/AppsNavBar.tsx b/src/components/Apps/AppsNavBar.tsx index a5c5c36..f3afcf6 100644 --- a/src/components/Apps/AppsNavBar.tsx +++ b/src/components/Apps/AppsNavBar.tsx @@ -33,8 +33,12 @@ import { sortablePinnedAppsAtom, } from "../../atoms/global"; -export function saveToLocalStorage(key, subKey, newValue) { +export function saveToLocalStorage(key, subKey, newValue, otherRootData = {}, deleteWholeKey) { try { + if(deleteWholeKey){ + localStorage.setItem(key, null); + return + } // Fetch existing data const existingData = localStorage.getItem(key); let combinedData = {}; @@ -45,12 +49,14 @@ export function saveToLocalStorage(key, subKey, newValue) { // Merge with the new data under the subKey combinedData = { ...parsedData, + ...otherRootData, timestamp: Date.now(), // Update the root timestamp [subKey]: newValue, // Assuming the data is an array }; } else { // If no existing data, just use the new data under the subKey combinedData = { + ...otherRootData, timestamp: Date.now(), // Set the initial root timestamp [subKey]: newValue, }; @@ -63,7 +69,6 @@ export function saveToLocalStorage(key, subKey, newValue) { console.error("Error saving to localStorage:", error); } } - export const AppsNavBar = () => { const [tabs, setTabs] = useState([]); const [selectedTab, setSelectedTab] = useState(null); diff --git a/src/components/Apps/useQortalMessageListener.tsx b/src/components/Apps/useQortalMessageListener.tsx index 98f6823..14b9d4b 100644 --- a/src/components/Apps/useQortalMessageListener.tsx +++ b/src/components/Apps/useQortalMessageListener.tsx @@ -3,6 +3,105 @@ import FileSaver from 'file-saver'; import { executeEvent } from '../../utils/events'; import { useSetRecoilState } from 'recoil'; import { navigationControllerAtom } from '../../atoms/global'; +import { extractComponents } from '../Chat/MessageDisplay'; + + +const missingFieldsFunc = (data, requiredFields)=> { + const missingFields: string[] = []; + requiredFields.forEach((field) => { + if (!data[field]) { + missingFields.push(field); + } + }); + if (missingFields.length > 0) { + const missingFieldsString = missingFields.join(", "); + const errorMsg = `Missing fields: ${missingFieldsString}`; + throw new Error(errorMsg); + } +} + +const encode = (value) => encodeURIComponent(value.trim()); // Helper to encode values +const buildQueryParams = (data) => { +const allowedParams= ["name", "service", "identifier", "mimeType", "fileName", "encryptionType", "key"] + return Object.entries(data) + .map(([key, value]) => { + if (value === undefined || value === null || value === false || !allowedParams.includes(key)) return null; // Skip null, undefined, or false + if (typeof value === "boolean") return `${key}=${value}`; // Handle boolean values + return `${key}=${encode(value)}`; // Encode other values + }) + .filter(Boolean) // Remove null values + .join("&"); // Join with `&` +}; +export const createAndCopyEmbedLink = async (data) => { + const requiredFields = [ + "type", + ]; + const missingFields: string[] = []; + requiredFields.forEach((field) => { + if (!data[field]) { + missingFields.push(field); + } + }); + if (missingFields.length > 0) { + const missingFieldsString = missingFields.join(", "); + const errorMsg = `Missing fields: ${missingFieldsString}`; + throw new Error(errorMsg); + } + + + switch (data.type) { + case "POLL": { + missingFieldsFunc(data, [ + "type", + "name" + ]) + + const queryParams = [ + `name=${encode(data.name)}`, + data.ref ? `ref=${encode(data.ref)}` : null, // Add only if ref exists + ] + .filter(Boolean) // Remove null values + .join("&"); // Join with `&` + const link = `qortal://use-embed/POLL?${queryParams}` + try { + await navigator.clipboard.writeText(link); + } catch (error) { + throw new Error('Failed to copy to clipboard.') + } + return link; + } + case "IMAGE": + case "ATTACHMENT": + { + missingFieldsFunc(data, [ + "type", + "name", + "service", + "identifier" + ]) + if(data?.encryptionType === 'private' && !data?.key){ + throw new Error('For an encrypted resource, you must provide the key to create the shared link') + } + const queryParams = buildQueryParams(data) + + const link = `qortal://use-embed/${data.type}?${queryParams}`; + + try { + await navigator.clipboard.writeText(link); + } catch (error) { + throw new Error('Failed to copy to clipboard.') + } + + return link; + } + + + default: + throw new Error('Invalid type') + } + +}; + class Semaphore { constructor(count) { this.count = count @@ -140,7 +239,7 @@ const UIQortalRequests = [ 'GET_TX_ACTIVITY_SUMMARY', 'GET_FOREIGN_FEE', 'UPDATE_FOREIGN_FEE', 'GET_SERVER_CONNECTION_HISTORY', 'SET_CURRENT_FOREIGN_SERVER', 'ADD_FOREIGN_SERVER', 'REMOVE_FOREIGN_SERVER', 'GET_DAY_SUMMARY', 'CREATE_TRADE_BUY_ORDER', - 'CREATE_TRADE_SELL_ORDER', 'CANCEL_TRADE_SELL_ORDER', 'IS_USING_GATEWAY', 'ADMIN_ACTION' + 'CREATE_TRADE_SELL_ORDER', 'CANCEL_TRADE_SELL_ORDER', 'IS_USING_GATEWAY', 'ADMIN_ACTION', 'SIGN_TRANSACTION', 'DECRYPT_QORTAL_GROUP_DATA' ]; @@ -350,6 +449,37 @@ isDOMContentLoaded: false }) }, []) + const openNewTab = async (data) => { + const requiredFields = [ + "qortalLink", + ]; + const missingFields: string[] = []; + requiredFields.forEach((field) => { + if (!data[field]) { + missingFields.push(field); + } + }); + if (missingFields.length > 0) { + const missingFieldsString = missingFields.join(", "); + const errorMsg = `Missing fields: ${missingFieldsString}`; + throw new Error(errorMsg); + } + + const res = extractComponents(data.qortalLink); + if (res) { + const { service, name, identifier, path } = res; + if(!service && !name) throw new Error('Invalid qortal link') + executeEvent("addTab", { data: { service, name, identifier, path } }); + executeEvent("open-apps-mode", { }); + return true + } else { + throw new Error("Invalid qortal link") + } + + + + }; + const resetHistory = useCallback(()=> { setHistory({ @@ -395,7 +525,7 @@ isDOMContentLoaded: false } else if ( event?.data?.action === 'PUBLISH_MULTIPLE_QDN_RESOURCES' || event?.data?.action === 'PUBLISH_QDN_RESOURCE' || - event?.data?.action === 'ENCRYPT_DATA' || event?.data?.action === 'SAVE_FILE' + event?.data?.action === 'ENCRYPT_DATA' || event?.data?.action === 'SAVE_FILE' || event?.data?.action === 'ENCRYPT_DATA_WITH_SHARING_KEY' || event?.data?.action === 'ENCRYPT_QORTAL_GROUP_DATA' ) { let data; @@ -469,6 +599,33 @@ isDOMContentLoaded: false name: event?.data?.payload?.name } }, '*' ); + } else if(event?.data?.action === 'OPEN_NEW_TAB'){ + try { + await openNewTab(event?.data?.payload) + event.ports[0].postMessage({ + result: true, + error: null, + }); + } catch (error) { + event.ports[0].postMessage({ + result: null, + error: error?.message, + }); + } + + } else if(event?.data?.action === 'CREATE_AND_COPY_EMBED_LINK'){ + try { + const link = await createAndCopyEmbedLink(event?.data?.payload) + event.ports[0].postMessage({ + result: link, + error: null, + }); + } catch (error) { + event.ports[0].postMessage({ + result: null, + error: error?.message, + }); + } } }; diff --git a/src/components/Chat/ChatDirect.tsx b/src/components/Chat/ChatDirect.tsx index 14470a7..1df0c15 100644 --- a/src/components/Chat/ChatDirect.tsx +++ b/src/components/Chat/ChatDirect.tsx @@ -28,6 +28,7 @@ const uid = new ShortUniqueId({ length: 5 }); export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDirect, setNewChat, getTimestampEnterChat, myName, balance, close, setMobileViewModeKeepOpen}) => { const { queueChats, addToQueue, processWithNewMessages} = useMessageQueue(); const [isFocusedParent, setIsFocusedParent] = useState(false); + const [messageSize, setMessageSize] = useState(0) const [messages, setMessages] = useState([]) const [isSending, setIsSending] = useState(false) @@ -43,6 +44,9 @@ export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDi const timeoutIdRef = useRef(null); const groupSocketTimeoutRef = useRef(null); const [replyMessage, setReplyMessage] = useState(null) + const [onEditMessage, setOnEditMessage] = useState(null) + const [chatReferences, setChatReferences] = useState({}) + const setEditorRef = (editorInstance) => { editorRef.current = editorInstance; }; @@ -65,10 +69,19 @@ export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDi const tempMessages = useMemo(()=> { if(!selectedDirect?.address) return [] if(queueChats[selectedDirect?.address]){ - return queueChats[selectedDirect?.address] + return queueChats[selectedDirect?.address]?.filter((item)=> !item?.chatReference) } return [] }, [selectedDirect?.address, queueChats]) + + const tempChatReferences = useMemo(()=> { + if(!selectedDirect?.address) return [] + if(queueChats[selectedDirect?.address]){ + return queueChats[selectedDirect?.address]?.filter((item)=> !!item?.chatReference) + } + return [] + }, [selectedDirect?.address, queueChats]) + useEffect(()=> { if(selectedDirect?.address){ publicKeyOfRecipientRef.current = selectedDirect?.address @@ -104,37 +117,63 @@ export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDi chrome?.runtime?.sendMessage({ action: "decryptDirect", payload: { data: encryptedMessages, involvingAddress: selectedDirect?.address - }}, (response) => { + }}, (decryptResponse) => { - if (!response?.error) { - - processWithNewMessages(response, selectedDirect?.address) - - res(response) - if(isInitiated){ + if (!decryptResponse?.error) { + const response = processWithNewMessages(decryptResponse, selectedDirect?.address); + res(response); - const formatted = response.map((item: any)=> { - return { + if (isInitiated) { + const formatted = response.filter((rawItem) => !rawItem?.chatReference).map((item) => ({ ...item, id: item.signature, text: item.message, - unread: item?.sender === myAddress ? false : true - } - } ) - setMessages((prev)=> [...prev, ...formatted]) - } else { - const formatted = response.map((item: any)=> { - return { - ...item, - id: item.signature, - text: item.message, - unread: false - } - } ) - setMessages(formatted) - hasInitialized.current = true + unread: item?.sender === myAddress ? false : true, + })); + setMessages((prev) => [...prev, ...formatted]); + setChatReferences((prev) => { + const organizedChatReferences = { ...prev }; - } + response.filter((rawItem) => !!rawItem?.chatReference && rawItem?.type === 'edit').forEach((item) => { + try { + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + edit: item + }; + } catch(error){ + + } + }) + return organizedChatReferences + }) + } else { + hasInitialized.current = true; + const formatted = response.filter((rawItem) => !rawItem?.chatReference) + .map((item) => ({ + ...item, + id: item.signature, + text: item.message, + unread: false, + })); + setMessages(formatted); + + setChatReferences((prev) => { + const organizedChatReferences = { ...prev }; + + response.filter((rawItem) => !!rawItem?.chatReference && rawItem?.type === 'edit').forEach((item) => { + try { + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + edit: item + }; + } catch(error){ + + } + }) + return organizedChatReferences + }) + } + return; } rej(response.error) }); @@ -291,6 +330,8 @@ const sendChatDirect = async ({ chatReference = undefined, messageText, otherDat } const clearEditorContent = () => { if (editorRef.current) { + setMessageSize(0) + editorRef.current.chain().focus().clearContent().run(); if(isMobile){ setTimeout(() => { @@ -305,80 +346,116 @@ const clearEditorContent = () => { } }; +useEffect(() => { + if (!editorRef?.current) return; + const handleUpdate = () => { + const htmlContent = editorRef?.current.getHTML(); + const stringified = JSON.stringify(htmlContent); + const size = new Blob([stringified]).size; + setMessageSize(size + 200); + }; - const sendMessage = async ()=> { - try { + // Add a listener for the editorRef?.current's content updates + editorRef?.current.on('update', handleUpdate); - - if(+balance < 4) throw new Error('You need at least 4 QORT to send a message') - if(isSending) return - if (editorRef.current) { - const htmlContent = editorRef.current.getHTML(); - - if(!htmlContent?.trim() || htmlContent?.trim() === '

') return - setIsSending(true) - pauseAllQueues() - const message = JSON.stringify(htmlContent) - - - if(isNewChat){ - await sendChatDirect({ messageText: htmlContent}, null, null, true) - return - } - let repliedTo = replyMessage?.signature + // Cleanup the listener on unmount + return () => { + editorRef?.current.off('update', handleUpdate); + }; +}, [editorRef?.current]); - if (replyMessage?.chatReference) { - repliedTo = replyMessage?.chatReference - } - const otherData = { - specialId: uid.rnd(), - repliedTo - } - const sendMessageFunc = async () => { - await sendChatDirect({ chatReference: undefined, messageText: htmlContent, otherData}, selectedDirect?.address, publicKeyOfRecipient, false) - }; - +const sendMessage = async ()=> { + try { + if(messageSize > 4000) return + + + if(+balance < 4) throw new Error('You need at least 4 QORT to send a message') + if(isSending) return + if (editorRef.current) { + const htmlContent = editorRef.current.getHTML(); + + if(!htmlContent?.trim() || htmlContent?.trim() === '

') return + setIsSending(true) + pauseAllQueues() + const message = JSON.stringify(htmlContent) + - // Add the function to the queue - const messageObj = { - message: { - text: htmlContent, - timestamp: Date.now(), - senderName: myName, - sender: myAddress, - ...(otherData || {}) - }, - - } - addToQueue(sendMessageFunc, messageObj, 'chat-direct', - selectedDirect?.address ); - setTimeout(() => { - executeEvent("sent-new-message-group", {}) - }, 150); - clearEditorContent() - setReplyMessage(null) - } - // send chat message - } catch (error) { - const errorMsg = error?.message || error - setInfoSnack({ - type: "error", - message: errorMsg === 'invalid signature' ? 'You need at least 4 QORT to send a message' : errorMsg, - }); - setOpenSnack(true); - console.error(error) - } finally { - setIsSending(false) - resumeAllQueues() - } + if(isNewChat){ + await sendChatDirect({ messageText: htmlContent}, null, null, true) + return } + let repliedTo = replyMessage?.signature + + if (replyMessage?.chatReference) { + repliedTo = replyMessage?.chatReference + } + let chatReference = onEditMessage?.signature + + const otherData = { + ...(onEditMessage?.decryptedData || {}), + specialId: uid.rnd(), + repliedTo: onEditMessage ? onEditMessage?.repliedTo : repliedTo, + type: chatReference ? 'edit' : '' + } + const sendMessageFunc = async () => { + return await sendChatDirect({ chatReference, messageText: htmlContent, otherData}, selectedDirect?.address, publicKeyOfRecipient, false) + }; + + + + // Add the function to the queue + const messageObj = { + message: { + timestamp: Date.now(), + senderName: myName, + sender: myAddress, + ...(otherData || {}), + text: htmlContent, + }, + chatReference + } + addToQueue(sendMessageFunc, messageObj, 'chat-direct', + selectedDirect?.address ); + setTimeout(() => { + executeEvent("sent-new-message-group", {}) + }, 150); + clearEditorContent() + setReplyMessage(null) + setOnEditMessage(null) + + } + // send chat message + } catch (error) { + const errorMsg = error?.message || error + setInfoSnack({ + type: "error", + message: errorMsg === 'invalid signature' ? 'You need at least 4 QORT to send a message' : errorMsg, + }); + setOpenSnack(true); + console.error(error) + } finally { + setIsSending(false) + resumeAllQueues() + } +} const onReply = useCallback((message)=> { + if(onEditMessage){ + clearEditorContent() + } setReplyMessage(message) + setOnEditMessage(null) editorRef?.current?.chain().focus() }, []) + const onEdit = useCallback((message)=> { + setOnEditMessage(message) + setReplyMessage(null) + editorRef.current.chain().focus().setContent(message?.text).run(); + + }, []) + return (
{ )} - +
{ { setReplyMessage(null) + setOnEditMessage(null) + }} + > + + + + )} + {onEditMessage && ( + + + + { + setReplyMessage(null) + setOnEditMessage(null) + + clearEditorContent() + + }} > @@ -533,6 +634,20 @@ const clearEditorContent = () => { )} + {messageSize > 750 && ( + + 4000 ? 'var(--danger)' : 'unset' + }}>{`Your message size is of ${messageSize} bytes out of a maximum of 4000`} + + + )}
{ +export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, myAddress, handleNewEncryptionNotification, hide, handleSecretKeyCreationInProgress, triedToFetchSecretKey, myName, balance, getTimestampEnterChatParent, isPrivate, hideView}) => { const [messages, setMessages] = useState([]) const [chatReferences, setChatReferences] = useState({}) const [isSending, setIsSending] = useState(false) @@ -41,9 +44,76 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, const timeoutIdRef = useRef(null); // Timeout ID reference const groupSocketTimeoutRef = useRef(null); // Group Socket Timeout reference const editorRef = useRef(null); + const [isOpenQManager, setIsOpenQManager] = useState(null) + const [onEditMessage, setOnEditMessage] = useState(null) + const [messageSize, setMessageSize] = useState(0) + const { queueChats, addToQueue, processWithNewMessages } = useMessageQueue(); const [, forceUpdate] = useReducer((x) => x + 1, 0); + const lastReadTimestamp = useRef(null) + const handleUpdateRef = useRef(null); + + + + + + const getTimestampEnterChat = async () => { + try { + return new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "getTimestampEnterChat", + }, + (response) => { + if (!response?.error && selectedGroup && response[selectedGroup]) { + lastReadTimestamp.current = response[selectedGroup] + chrome?.runtime?.sendMessage({ + action: "addTimestampEnterChat", + payload: { + timestamp: Date.now(), + groupId: selectedGroup, + }, + }, (response2)=> { + setTimeout(() => { + getTimestampEnterChatParent(); + }, 200); + }) + res(response); + } + rej(response.error); + } + ); + }); + } catch (error) {} + }; + + useEffect(()=> { + getTimestampEnterChat() + }, []) + + const openQManager = useCallback(()=> { + setIsOpenQManager(true) + }, []) + const members = useMemo(() => { + const uniqueMembers = new Set(); + + messages.forEach((message) => { + if (message?.senderName) { + uniqueMembers.add(message?.senderName); + } + }); + + return Array.from(uniqueMembers); + }, [messages]); + + const onEdit = useCallback((message)=> { + setOnEditMessage(message) + setReplyMessage(null) + editorRef.current.chain().focus().setContent(message?.messageText || message?.text).run(); + + }, []) + const triggerRerender = () => { forceUpdate(); // Trigger re-render by updating the state }; @@ -118,196 +188,215 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, } - const decryptMessages = (encryptedMessages: any[], isInitiated: boolean )=> { - try { - if(!secretKeyRef.current){ - checkForFirstSecretKeyNotification(encryptedMessages) - return - } - return new Promise((res, rej)=> { - chrome?.runtime?.sendMessage({ action: "decryptSingle", payload: { - data: encryptedMessages, - secretKeyObject: secretKey - }}, (response) => { + const decryptMessages = (encryptedMessages: any[], isInitiated: boolean )=> { + try { + if(!secretKeyRef.current){ + checkForFirstSecretKeyNotification(encryptedMessages) + } + return new Promise((res, rej)=> { + chrome?.runtime?.sendMessage({ action: "decryptSingle", payload: { + data: encryptedMessages, + secretKeyObject: secretKey + }}, (response) => { if (!response?.error) { - const filterUImessages = encryptedMessages.filter((item)=> !isExtMsg(item.data)) - const decodedUIMessages = decodeBase64ForUIChatMessages(filterUImessages) - - const combineUIAndExtensionMsgs = [...decodedUIMessages, ...response] - processWithNewMessages(combineUIAndExtensionMsgs?.map((item)=> { - return { + const filterUIMessages = encryptedMessages.filter((item) => !isExtMsg(item.data)); + const decodedUIMessages = decodeBase64ForUIChatMessages(filterUIMessages); + + const combineUIAndExtensionMsgsBefore = [...decodedUIMessages, ...response]; + const combineUIAndExtensionMsgs = processWithNewMessages( + combineUIAndExtensionMsgsBefore.map((item) => ({ ...item, - ...(item?.decryptedData || {}) - } - }), selectedGroup) - res(combineUIAndExtensionMsgs) - if(isInitiated){ - - const formatted = combineUIAndExtensionMsgs.filter((rawItem)=> !rawItem?.chatReference).map((item: any)=> { - return { - ...item, - id: item.signature, - text: item?.decryptedData?.message || "", - repliedTo: item?.repliedTo || item?.decryptedData?.repliedTo, - unread: item?.sender === myAddress ? false : !!item?.chatReference ? false : true, - isNotEncrypted: !!item?.messageText - } - } ) - setMessages((prev)=> [...prev, ...formatted]) + ...(item?.decryptedData || {}), + })), + selectedGroup + ); + res(combineUIAndExtensionMsgs); + + if (isInitiated) { - + const formatted = combineUIAndExtensionMsgs + .filter((rawItem) => !rawItem?.chatReference) + .map((item) => { + const additionalFields = item?.data === 'NDAwMQ==' ? { + text: "

First group key created.

" + } : {} + return { + ...item, + id: item.signature, + text: item?.decryptedData?.message || "", + repliedTo: item?.repliedTo || item?.decryptedData?.repliedTo, + unread: item?.sender === myAddress ? false : !!item?.chatReference ? false : true, + isNotEncrypted: !!item?.messageText, + ...additionalFields + } + }); + setMessages((prev) => [...prev, ...formatted]); + setChatReferences((prev) => { - let organizedChatReferences = { ...prev }; - + const organizedChatReferences = { ...prev }; combineUIAndExtensionMsgs - .filter((rawItem) => rawItem && rawItem.chatReference && rawItem.decryptedData?.type === 'reaction') + .filter((rawItem) => rawItem && rawItem.chatReference && (rawItem?.decryptedData?.type === "reaction" || rawItem?.decryptedData?.type === "edit" || rawItem?.type === "edit" || rawItem?.isEdited || rawItem?.type === "reaction")) .forEach((item) => { try { - const content = item.decryptedData?.content; - const sender = item.sender; - const newTimestamp = item.timestamp; - const contentState = item.decryptedData?.contentState; - - if (!content || typeof content !== 'string' || !sender || typeof sender !== 'string' || !newTimestamp) { - console.warn("Invalid content, sender, or timestamp in reaction data", item); - return; - } - - // Initialize chat reference and reactions if not present - organizedChatReferences[item.chatReference] = { - ...(organizedChatReferences[item.chatReference] || {}), - reactions: organizedChatReferences[item.chatReference]?.reactions || {} - }; - - organizedChatReferences[item.chatReference].reactions[content] = - organizedChatReferences[item.chatReference].reactions[content] || []; - - // Remove any existing reactions from the same sender before adding the new one - let latestTimestampForSender = null; - - // Track the latest reaction timestamp for the same content and sender - organizedChatReferences[item.chatReference].reactions[content] = - organizedChatReferences[item.chatReference].reactions[content].filter((reaction) => { - if (reaction.sender === sender) { - // Track the latest timestamp for this sender - latestTimestampForSender = Math.max(latestTimestampForSender || 0, reaction.timestamp); - } - return reaction.sender !== sender; - }); - - // Compare with the latest tracked timestamp for this sender - if (latestTimestampForSender && newTimestamp < latestTimestampForSender) { - // Ignore this item if it's older than the latest known reaction - return; - } - - // Add the new reaction only if contentState is true - if (contentState !== false) { - organizedChatReferences[item.chatReference].reactions[content].push(item); - } - - // If the reactions for a specific content are empty, clean up the object - if (organizedChatReferences[item.chatReference].reactions[content].length === 0) { - delete organizedChatReferences[item.chatReference].reactions[content]; + if(item?.decryptedData?.type === "edit"){ + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + edit: item.decryptedData, + }; + } else if(item?.type === "edit" || item?.isEdited){ + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + edit: item, + }; + } else { + const content = item?.content || item.decryptedData?.content; + const sender = item.sender; + const newTimestamp = item.timestamp; + const contentState = item?.contentState || item.decryptedData?.contentState; + + if (!content || typeof content !== "string" || !sender || typeof sender !== "string" || !newTimestamp) { + console.warn("Invalid content, sender, or timestamp in reaction data", item); + return; + } + + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + reactions: organizedChatReferences[item.chatReference]?.reactions || {}, + }; + + organizedChatReferences[item.chatReference].reactions[content] = + organizedChatReferences[item.chatReference].reactions[content] || []; + + let latestTimestampForSender = null; + + organizedChatReferences[item.chatReference].reactions[content] = + organizedChatReferences[item.chatReference].reactions[content].filter((reaction) => { + if (reaction.sender === sender) { + latestTimestampForSender = Math.max(latestTimestampForSender || 0, reaction.timestamp); + } + return reaction.sender !== sender; + }); + + if (latestTimestampForSender && newTimestamp < latestTimestampForSender) { + return; + } + + if (contentState !== false) { + organizedChatReferences[item.chatReference].reactions[content].push(item); + } + + if (organizedChatReferences[item.chatReference].reactions[content].length === 0) { + delete organizedChatReferences[item.chatReference].reactions[content]; + } } + } catch (error) { - console.error("Error processing reaction item:", error, item); + console.error("Error processing reaction/edit item:", error, item); } }); - + return organizedChatReferences; }); - - - } else { - const formatted = combineUIAndExtensionMsgs.filter((rawItem)=> !rawItem?.chatReference).map((item: any)=> { - return { - ...item, - id: item.signature, - text: item?.decryptedData?.message || "", - repliedTo: item?.repliedTo || item?.decryptedData?.repliedTo, - isNotEncrypted: !!item?.messageText, - unread: false - } - } ) - setMessages(formatted) - + let firstUnreadFound = false; + const formatted = combineUIAndExtensionMsgs + .filter((rawItem) => !rawItem?.chatReference) + .map((item) => { + const additionalFields = item?.data === 'NDAwMQ==' ? { + text: "

First group key created.

" + } : {} + const divide = lastReadTimestamp.current && !firstUnreadFound && item.timestamp > lastReadTimestamp.current && myAddress !== item?.sender; + + if(divide){ + firstUnreadFound = true + } + return { + ...item, + id: item.signature, + text: item?.decryptedData?.message || "", + repliedTo: item?.repliedTo || item?.decryptedData?.repliedTo, + isNotEncrypted: !!item?.messageText, + unread: false, + divide, + ...additionalFields + } + }); + setMessages(formatted); + setChatReferences((prev) => { - let organizedChatReferences = { ...prev }; - + const organizedChatReferences = { ...prev }; + combineUIAndExtensionMsgs - .filter((rawItem) => rawItem && rawItem.chatReference && rawItem.decryptedData?.type === 'reaction') + .filter((rawItem) => rawItem && rawItem.chatReference && (rawItem?.decryptedData?.type === "reaction" || rawItem?.decryptedData?.type === "edit" || rawItem?.type === "edit" || rawItem?.isEdited || rawItem?.type === "reaction")) .forEach((item) => { try { - const content = item.decryptedData?.content; + if(item?.decryptedData?.type === "edit"){ + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + edit: item.decryptedData, + }; + } else if(item?.type === "edit" || item?.isEdited){ + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + edit: item, + }; + } else { + const content = item?.content || item.decryptedData?.content; const sender = item.sender; const newTimestamp = item.timestamp; - const contentState = item.decryptedData?.contentState; - - if (!content || typeof content !== 'string' || !sender || typeof sender !== 'string' || !newTimestamp) { + const contentState = item?.contentState || item.decryptedData?.contentState; + + if (!content || typeof content !== "string" || !sender || typeof sender !== "string" || !newTimestamp) { console.warn("Invalid content, sender, or timestamp in reaction data", item); return; } - - // Initialize chat reference and reactions if not present + organizedChatReferences[item.chatReference] = { ...(organizedChatReferences[item.chatReference] || {}), - reactions: organizedChatReferences[item.chatReference]?.reactions || {} + reactions: organizedChatReferences[item.chatReference]?.reactions || {}, }; - + organizedChatReferences[item.chatReference].reactions[content] = organizedChatReferences[item.chatReference].reactions[content] || []; - - // Remove any existing reactions from the same sender before adding the new one + let latestTimestampForSender = null; - - // Track the latest reaction timestamp for the same content and sender - organizedChatReferences[item.chatReference].reactions[content] = + + organizedChatReferences[item.chatReference].reactions[content] = organizedChatReferences[item.chatReference].reactions[content].filter((reaction) => { if (reaction.sender === sender) { - // Track the latest timestamp for this sender latestTimestampForSender = Math.max(latestTimestampForSender || 0, reaction.timestamp); } return reaction.sender !== sender; }); - - // Compare with the latest tracked timestamp for this sender + if (latestTimestampForSender && newTimestamp < latestTimestampForSender) { - // Ignore this item if it's older than the latest known reaction return; } - - // Add the new reaction only if contentState is true + if (contentState !== false) { organizedChatReferences[item.chatReference].reactions[content].push(item); } - - // If the reactions for a specific content are empty, clean up the object + if (organizedChatReferences[item.chatReference].reactions[content].length === 0) { delete organizedChatReferences[item.chatReference].reactions[content]; } + } } catch (error) { console.error("Error processing reaction item:", error, item); } }); - + return organizedChatReferences; }); - - - - - } } - rej(response.error) - }); - }) - } catch (error) { - - } + rej(response.error); + }); + }) + } catch (error) { + } + } @@ -386,10 +475,11 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, setIsLoading(true) initWebsocketMessageGroup() } - }, [triedToFetchSecretKey, secretKey]) + }, [triedToFetchSecretKey, secretKey, isPrivate]) useEffect(()=> { - if(!secretKey || hasInitializedWebsocket.current) return + if(isPrivate === null) return + if(isPrivate === false || !secretKey || hasInitializedWebsocket.current) return forceCloseWebSocket() setMessages([]) setIsLoading(true) @@ -399,7 +489,7 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, }, 6000); initWebsocketMessageGroup() hasInitializedWebsocket.current = true - }, [secretKey]) + }, [secretKey, isPrivate]) useEffect(()=> { @@ -469,74 +559,120 @@ const clearEditorContent = () => { }; - const sendMessage = async ()=> { - try { - if(isSending) return - if(+balance < 4) throw new Error('You need at least 4 QORT to send a message') - pauseAllQueues() - if (editorRef.current) { - const htmlContent = editorRef.current.getHTML(); - - if(!htmlContent?.trim() || htmlContent?.trim() === '

') return - setIsSending(true) - const message = htmlContent - const secretKeyObject = await getSecretKey(false, true) +const sendMessage = async ()=> { + try { + if(messageSize > 4000) return + if(isPrivate === null) throw new Error('Unable to determine if group is private') + if(isSending) return + if(+balance < 4) throw new Error('You need at least 4 QORT to send a message') + pauseAllQueues() + if (editorRef.current) { + const htmlContent = editorRef.current.getHTML(); + + if(!htmlContent?.trim() || htmlContent?.trim() === '

') return + - let repliedTo = replyMessage?.signature + setIsSending(true) + const message = isPrivate === false ? editorRef.current.getJSON() : htmlContent + const secretKeyObject = await getSecretKey(false, true) - if (replyMessage?.chatReference) { - repliedTo = replyMessage?.chatReference - } - const otherData = { - specialId: uid.rnd(), - repliedTo - } - const objectMessage = { - message, - ...(otherData || {}) - } - const message64: any = await objectToBase64(objectMessage) - - const encryptSingle = await encryptChatMessage(message64, secretKeyObject) - // const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle}) - - const sendMessageFunc = async () => { - await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle}) - }; - - // Add the function to the queue - const messageObj = { - message: { - text: message, - timestamp: Date.now(), - senderName: myName, - sender: myAddress, - ...(otherData || {}) - }, - - } - addToQueue(sendMessageFunc, messageObj, 'chat', - selectedGroup ); - setTimeout(() => { - executeEvent("sent-new-message-group", {}) - }, 150); - clearEditorContent() - setReplyMessage(null) - } - // send chat message - } catch (error) { - const errorMsg = error?.message || error - setInfoSnack({ - type: "error", - message: errorMsg, - }); - setOpenSnack(true); - console.error(error) - } finally { - setIsSending(false) - resumeAllQueues() - } + let repliedTo = replyMessage?.signature + + if (replyMessage?.chatReference) { + repliedTo = replyMessage?.chatReference } + let chatReference = onEditMessage?.signature + + const publicData = isPrivate ? {} : { + isEdited : chatReference ? true : false, + } + const otherData = { + repliedTo, + ...(onEditMessage?.decryptedData || {}), + type: chatReference ? 'edit' : '', + specialId: uid.rnd(), + ...publicData + } + const objectMessage = { + ...(otherData || {}), + [isPrivate ? 'message' : 'messageText']: message, + version: 3 + } + const message64: any = await objectToBase64(objectMessage) + + const encryptSingle = isPrivate === false ? JSON.stringify(objectMessage) : await encryptChatMessage(message64, secretKeyObject) + // const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle}) + + const sendMessageFunc = async () => { + return await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle, chatReference}) + }; + + // Add the function to the queue + const messageObj = { + message: { + text: htmlContent, + timestamp: Date.now(), + senderName: myName, + sender: myAddress, + ...(otherData || {}) + }, + chatReference + } + addToQueue(sendMessageFunc, messageObj, 'chat', + selectedGroup ); + setTimeout(() => { + executeEvent("sent-new-message-group", {}) + }, 150); + clearEditorContent() + setReplyMessage(null) + setOnEditMessage(null) + } + // send chat message + } catch (error) { + const errorMsg = error?.message || error + setInfoSnack({ + type: "error", + message: errorMsg, + }); + setOpenSnack(true); + console.error(error) + } finally { + setIsSending(false) + resumeAllQueues() + } +} + +useEffect(() => { + if (!editorRef?.current) return; + + handleUpdateRef.current = throttle(async () => { + try { + if(isPrivate){ + const htmlContent = editorRef.current.getHTML(); + const message64 = await objectToBase64(JSON.stringify(htmlContent)) + const secretKeyObject = await getSecretKey(false, true) + const encryptSingle = await encryptChatMessage(message64, secretKeyObject) + setMessageSize((encryptSingle?.length || 0) + 200); + } else { + const htmlContent = editorRef.current.getJSON(); + const message = JSON.stringify(htmlContent) + const size = new Blob([message]).size + setMessageSize(size + 300); + } + + } catch (error) { + // calc size error + } + }, 1200); + + const currentEditor = editorRef.current; + + currentEditor.on("update", handleUpdateRef.current); + + return () => { + currentEditor.off("update", handleUpdateRef.current); + }; +}, [editorRef, setMessageSize, isPrivate]); useEffect(() => { if (hide) { @@ -547,7 +683,11 @@ const clearEditorContent = () => { }, [hide]); const onReply = useCallback((message)=> { + if(onEditMessage){ + clearEditorContent() + } setReplyMessage(message) + setOnEditMessage(null) editorRef?.current?.chain().focus() }, []) @@ -576,11 +716,11 @@ const clearEditorContent = () => { } const message64: any = await objectToBase64(objectMessage) const reactiontypeNumber = RESOURCE_TYPE_NUMBER_GROUP_CHAT_REACTIONS - const encryptSingle = await encryptChatMessage(message64, secretKeyObject, reactiontypeNumber) + const encryptSingle = isPrivate === false ? JSON.stringify(objectMessage) : await encryptChatMessage(message64, secretKeyObject, reactiontypeNumber) // const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle}) const sendMessageFunc = async () => { - await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle, chatReference: chatMessage.signature}) + return await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle, chatReference: chatMessage.signature}) }; // Add the function to the queue @@ -616,6 +756,8 @@ const clearEditorContent = () => { resumeAllQueues() } }, []) + + console.log('isPrivate', isPrivate) return (
{ left: hide && '-100000px', }}> - - + + {(!!secretKey || isPrivate === false) && (
{ zIndex: isFocusedParent ? 5 : 'unset', flexShrink: 0 }}> +
{replyMessage && ( { { setReplyMessage(null) + + setOnEditMessage(null) + + }} + > + + + + )} + {onEditMessage && ( + + + + { + setReplyMessage(null) + setOnEditMessage(null) + + clearEditorContent() + }} > @@ -676,40 +845,35 @@ const clearEditorContent = () => { )} - + + {messageSize > 750 && ( + + 4000 ? 'var(--danger)' : 'unset' + }}>{`Your message size is of ${messageSize} bytes out of a maximum of 4000`} + + + )}
+ - {isFocusedParent && ( - { - if(isSending) return - setIsFocusedParent(false) - clearEditorContent() - // Unfocus the editor - }} - style={{ - marginTop: 'auto', - alignSelf: 'center', - cursor: isSending ? 'default' : 'pointer', - background: 'red', - flexShrink: 0, - padding: isMobile && '5px' - }} - > - - {` Close`} - - - )} + { + if(isSending) return sendMessage() }} @@ -719,7 +883,9 @@ const clearEditorContent = () => { cursor: isSending ? 'default' : 'pointer', background: isSending && 'rgba(0, 0, 0, 0.8)', flexShrink: 0, - padding: isMobile && '5px', + padding: '5px', + width: '100px', + minWidth: 'auto' }} > @@ -740,8 +906,57 @@ const clearEditorContent = () => { - {/* */}
+ )} + {isOpenQManager !== null && ( + + + + Q-Manager + { + setIsOpenQManager(false) + }}> + + + + + + )} {/* */} { const parentRef = useRef(); const [messages, setMessages] = useState(initialMessages); const [showScrollButton, setShowScrollButton] = useState(false); + const [showScrollDownButton, setShowScrollDownButton] = useState(false); const hasLoadedInitialRef = useRef(false); - const isAtBottomRef = useRef(true); - // const [ref, inView] = useInView({ - // threshold: 0.7 - // }) + const scrollingIntervalRef = useRef(null); + const lastSeenUnreadMessageTimestamp = useRef(null); + console.log('messages', messages) + // Initialize the virtualizer + const rowVirtualizer = useVirtualizer({ + count: messages.length, + getItemKey: (index) => messages[index]?.tempSignature || messages[index].signature, + getScrollElement: () => parentRef?.current, + estimateSize: useCallback(() => 80, []), // Provide an estimated height of items, adjust this as needed + overscan: 10, // Number of items to render outside the visible area to improve smoothness + }); - // useEffect(() => { - // if (inView) { + const isAtBottom = useMemo(()=> { + if (parentRef.current && rowVirtualizer?.isScrolling !== undefined) { + const { scrollTop, scrollHeight, clientHeight } = parentRef.current; + const atBottom = scrollTop + clientHeight >= scrollHeight - 10; // Adjust threshold as needed + return atBottom + } + + return false + + }, [rowVirtualizer?.isScrolling]) + + useEffect(() => { + if (!parentRef.current || rowVirtualizer?.isScrolling === undefined) return; + if(isAtBottom){ + if (scrollingIntervalRef.current) { + clearTimeout(scrollingIntervalRef.current); + } + setShowScrollDownButton(false); + return; + } else + if (rowVirtualizer?.isScrolling) { + if (scrollingIntervalRef.current) { + clearTimeout(scrollingIntervalRef.current); + } + setShowScrollDownButton(false); + return; + } + const { scrollTop, scrollHeight, clientHeight } = parentRef.current; + const atBottom = scrollHeight - scrollTop - clientHeight <= 300; + if (!atBottom) { + scrollingIntervalRef.current = setTimeout(() => { + setShowScrollDownButton(true); + }, 250); + } else { + setShowScrollDownButton(false); + } + }, [rowVirtualizer?.isScrolling, isAtBottom]); - // } - // }, [inView]) // Update message list with unique signatures and tempMessages useEffect(() => { let uniqueInitialMessagesMap = new Map(); @@ -56,28 +108,38 @@ export const ChatList = ({ setTimeout(() => { const hasUnreadMessages = totalMessages.some( - (msg) => msg.unread && !msg?.chatReference + (msg) => msg.unread && !msg?.chatReference && !msg?.isTemp && (!msg?.chatReference && msg?.timestamp > lastSeenUnreadMessageTimestamp.current || 0) ); if (parentRef.current) { const { scrollTop, scrollHeight, clientHeight } = parentRef.current; const atBottom = scrollTop + clientHeight >= scrollHeight - 10; // Adjust threshold as needed if (!atBottom && hasUnreadMessages) { setShowScrollButton(hasUnreadMessages); + setShowScrollDownButton(false); } else { handleMessageSeen(); } } if (!hasLoadedInitialRef.current) { - scrollToBottom(totalMessages); + const findDivideIndex = totalMessages.findIndex( + (item) => !!item?.divide + ); + const divideIndex = + findDivideIndex !== -1 ? findDivideIndex : undefined; + scrollToBottom(totalMessages, divideIndex); hasLoadedInitialRef.current = true; } }, 500); }, [initialMessages, tempMessages]); - const scrollToBottom = (initialMsgs) => { + const scrollToBottom = (initialMsgs, divideIndex) => { const index = initialMsgs ? initialMsgs.length - 1 : messages.length - 1; if (rowVirtualizer) { - rowVirtualizer.scrollToIndex(index, { align: "end" }); + if (divideIndex) { + rowVirtualizer.scrollToIndex(divideIndex, { align: "start" }); + } else { + rowVirtualizer.scrollToIndex(index, { align: "end" }); + } } handleMessageSeen(); }; @@ -90,17 +152,17 @@ export const ChatList = ({ })) ); setShowScrollButton(false); + lastSeenUnreadMessageTimestamp.current = Date.now() }, []); - // const scrollToBottom = (initialMsgs) => { - // const index = initialMsgs ? initialMsgs.length - 1 : messages.length - 1; - // if (parentRef.current) { - // parentRef.current.scrollToIndex(index); - // } - // }; - const sentNewMessageGroupFunc = useCallback(() => { - scrollToBottom(); + const { scrollHeight, scrollTop, clientHeight } = parentRef.current; + + // Check if the user is within 200px from the bottom + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + if (distanceFromBottom <= 700) { + scrollToBottom(); + } }, [messages]); useEffect(() => { @@ -116,153 +178,250 @@ export const ChatList = ({ return messages[lastIndex]?.signature; }, [messages]); - // Initialize the virtualizer - const rowVirtualizer = useVirtualizer({ - count: messages.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 80, // Provide an estimated height of items, adjust this as needed - overscan: 10, // Number of items to render outside the visible area to improve smoothness - getItemKey: React.useCallback( - (index) => messages[index].signature, - [messages] - ), - }); - + const goToMessage = useCallback((idx) => { + rowVirtualizer.scrollToIndex(idx); + }, []); return ( -
- {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const index = virtualRow.index; - let message = messages[index]; - let replyIndex = messages.findIndex( - (msg) => msg?.signature === message?.repliedTo - ); - let reply; - let reactions = null; +
+ + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const index = virtualRow.index; + let message = messages[index] || null; // Safeguard against undefined + let replyIndex = -1; + let reply = null; + let reactions = null; + let isUpdating = false; + + try { + // Safeguard for message existence + if (message) { + // Check for repliedTo logic + replyIndex = messages.findIndex( + (msg) => msg?.signature === message?.repliedTo + ); + + if (message?.repliedTo && replyIndex !== -1) { + reply = { ...(messages[replyIndex] || {}) }; + if (chatReferences?.[reply?.signature]?.edit) { + reply.decryptedData = chatReferences[reply?.signature]?.edit; + reply.text = chatReferences[reply?.signature]?.edit?.message; + } + } + + // GroupDirectId logic + if (message?.message && message?.groupDirectId) { + replyIndex = messages.findIndex( + (msg) => msg?.signature === message?.message?.repliedTo + ); + if (message?.message?.repliedTo && replyIndex !== -1) { + reply = messages[replyIndex] || null; + } + message = { + ...(message?.message || {}), + isTemp: true, + unread: false, + status: message?.status, + }; + } + + // Check for reactions and edits + if (chatReferences?.[message.signature]) { + reactions = chatReferences[message.signature]?.reactions || null; + + if (chatReferences[message.signature]?.edit?.message && message?.text) { + message.text = chatReferences[message.signature]?.edit?.message; + message.isEdit = true + } + if (chatReferences[message.signature]?.edit?.messageText && message?.messageText) { + message.messageText = chatReferences[message.signature]?.edit?.messageText; + message.isEdit = true + } + + } + + // Check if message is updating + if ( + tempChatReferences?.some( + (item) => item?.chatReference === message?.signature + ) + ) { + isUpdating = true; + } + } + } catch (error) { + console.error("Error processing message:", error, { index, message }); + // Gracefully handle the error by providing fallback data + message = null; + reply = null; + reactions = null; + } + // Render fallback if message is null + if (!message) { + return ( +
+ Error loading message. +
+ ); + } - if (message?.repliedTo && replyIndex !== -1) { - reply = messages[replyIndex]; - } - - if (message?.message && message?.groupDirectId) { - replyIndex = messages.findIndex( - (msg) => msg?.signature === message?.message?.repliedTo + return ( +
+ + Error loading content: Invalid Data + + } + > + + +
); - if (message?.message?.repliedTo && replyIndex !== -1) { - reply = messages[replyIndex]; - } - message = { - ...(message?.message || {}), - isTemp: true, - unread: false, - status: message?.status, - }; - } - - if (chatReferences && chatReferences[message?.signature]) { - if (chatReferences[message.signature]?.reactions) { - reactions = chatReferences[message.signature]?.reactions; - } - } - - let isUpdating = false; - if ( - tempChatReferences && - tempChatReferences?.find( - (item) => item?.chatReference === message?.signature - ) - ) { - isUpdating = true; - } - - return ( -
rowVirtualizer.measureElement(node)} //measure dynamic row height - key={message.signature} - style={{ - position: "absolute", - top: 0, - left: "50%", // Move to the center horizontally - transform: `translateY(${virtualRow.start}px) translateX(-50%)`, // Adjust for centering - width: "100%", // Control width (90% of the parent) - padding: "10px 0", - display: "flex", - justifyContent: "center", - overscrollBehavior: "none", - }} - > - rowVirtualizer.scrollToIndex(idx)} - handleReaction={handleReaction} - reactions={reactions} - isUpdating={isUpdating} - /> -
- ); - })} + })} + +
+ {showScrollButton && ( + + )} + {showScrollDownButton && !showScrollButton && ( + + )}
- {showScrollButton && ( - + {enableMentions && (hasSecretKey || isPrivate === false) && ( + )} -
+ ); }; diff --git a/src/components/Chat/ChatOptions.tsx b/src/components/Chat/ChatOptions.tsx new file mode 100644 index 0000000..c930a89 --- /dev/null +++ b/src/components/Chat/ChatOptions.tsx @@ -0,0 +1,718 @@ +import { + Avatar, + Box, + ButtonBase, + InputBase, + MenuItem, + Select, + Typography, +} from "@mui/material"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import SearchIcon from "@mui/icons-material/Search"; +import { Spacer } from "../../common/Spacer"; +import AlternateEmailIcon from "@mui/icons-material/AlternateEmail"; +import CloseIcon from "@mui/icons-material/Close"; +import InsertLinkIcon from "@mui/icons-material/InsertLink"; +import Highlight from "@tiptap/extension-highlight"; +import Mention from "@tiptap/extension-mention"; +import StarterKit from "@tiptap/starter-kit"; +import Underline from "@tiptap/extension-underline"; +import { + AppsSearchContainer, + AppsSearchLeft, + AppsSearchRight, +} from "../Apps/Apps-styles"; +import IconSearch from "../../assets/svgs/Search.svg"; +import IconClearInput from "../../assets/svgs/ClearInput.svg"; +import { + AutoSizer, + CellMeasurer, + CellMeasurerCache, + List, +} from "react-virtualized"; +import { getBaseApiReact } from "../../App"; +import { MessageDisplay } from "./MessageDisplay"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { formatTimestamp } from "../../utils/time"; +import { ContextMenuMentions } from "../ContextMenuMentions"; +import { convert } from "html-to-text"; +import { generateHTML } from "@tiptap/react"; +import ErrorBoundary from "../../common/ErrorBoundary"; + +const extractTextFromHTML = (htmlString = "") => { + return convert(htmlString, { + wordwrap: false, // Disable word wrapping + })?.toLowerCase(); +}; +const cache = new CellMeasurerCache({ + fixedWidth: true, + defaultHeight: 50, +}); + +export const ChatOptions = ({ + messages: untransformedMessages, + goToMessage, + members, + myName, + selectedGroup, + openQManager, + isPrivate, +}) => { + const [mode, setMode] = useState("default"); + const [searchValue, setSearchValue] = useState(""); + const [selectedMember, setSelectedMember] = useState(0); + + const parentRef = useRef(); + const parentRefMentions = useRef(); + const [lastMentionTimestamp, setLastMentionTimestamp] = useState(null); + const [debouncedValue, setDebouncedValue] = useState(""); // Debounced value + const messages = useMemo(() => { + return untransformedMessages?.map((item) => { + if (item?.messageText) { + let transformedMessage = item?.messageText; + try { + transformedMessage = generateHTML(item?.messageText, [ + StarterKit, + Underline, + Highlight, + Mention, + ]); + return { + ...item, + messageText: transformedMessage, + }; + } catch (error) { + // error + } + } else return item; + }); + }, [untransformedMessages]); + const getTimestampMention = async () => { + try { + return new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "getTimestampMention", + }, + (response) => { + if (!response?.error && selectedGroup && response[selectedGroup]) { + setLastMentionTimestamp(response[selectedGroup]); + res(response); + } + rej(response.error); + } + ); + }); + } catch (error) {} + }; + + useEffect(() => { + if (mode === "mentions" && selectedGroup) { + chrome?.runtime?.sendMessage( + { + action: "addTimestampMention", + payload: { + timestamp: Date.now(), + groupId: selectedGroup, + } + }, + (response) => { + if (!response?.error) { + getTimestampMention(); + } + } + ); + + } + }, [mode, selectedGroup]); + + useEffect(() => { + getTimestampMention(); + }, []); + + // Debounce logic + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(searchValue); + }, 350); + + // Cleanup timeout if searchValue changes before the timeout completes + return () => { + clearTimeout(handler); + }; + }, [searchValue]); // Runs effect when searchValue changes + + const searchedList = useMemo(() => { + if (!debouncedValue?.trim()) { + if (selectedMember) { + return messages + .filter((message) => message?.senderName === selectedMember) + ?.sort((a, b) => b?.timestamp - a?.timestamp); + } + return []; + } + if (selectedMember) { + return messages + .filter( + (message) => + message?.senderName === selectedMember && + extractTextFromHTML( + isPrivate ? message?.messageText : message?.decryptedData?.message + )?.includes(debouncedValue.toLowerCase()) + ) + ?.sort((a, b) => b?.timestamp - a?.timestamp); + } + return messages + .filter((message) => + extractTextFromHTML( + isPrivate === false + ? message?.messageText + : message?.decryptedData?.message + )?.includes(debouncedValue.toLowerCase()) + ) + ?.sort((a, b) => b?.timestamp - a?.timestamp); + }, [debouncedValue, messages, selectedMember, isPrivate]); + + const mentionList = useMemo(() => { + if (!messages || messages.length === 0 || !myName) return []; + if (isPrivate === false) { + return messages + .filter((message) => + extractTextFromHTML(message?.messageText)?.includes(`@${myName}`) + ) + ?.sort((a, b) => b?.timestamp - a?.timestamp); + } + return messages + .filter((message) => + extractTextFromHTML(message?.decryptedData?.message)?.includes( + `@${myName}` + ) + ) + ?.sort((a, b) => b?.timestamp - a?.timestamp); + }, [messages, myName, isPrivate]); + + const rowVirtualizer = useVirtualizer({ + count: searchedList.length, + getItemKey: React.useCallback( + (index) => searchedList[index].signature, + [searchedList] + ), + getScrollElement: () => parentRef.current, + estimateSize: () => 80, // Provide an estimated height of items, adjust this as needed + overscan: 10, // Number of items to render outside the visible area to improve smoothness + }); + + const rowVirtualizerMentions = useVirtualizer({ + count: mentionList.length, + getItemKey: React.useCallback( + (index) => mentionList[index].signature, + [mentionList] + ), + getScrollElement: () => parentRefMentions.current, + estimateSize: () => 80, // Provide an estimated height of items, adjust this as needed + overscan: 10, // Number of items to render outside the visible area to improve smoothness + }); + + if (mode === "mentions") { + return ( + + + { + setMode("default"); + }} + sx={{ + cursor: "pointer", + color: "white", + }} + /> + + + {mentionList?.length === 0 && ( + + No results + + )} + +
+
+
+
+ {rowVirtualizerMentions + .getVirtualItems() + .map((virtualRow) => { + const index = virtualRow.index; + let message = mentionList[index]; + return ( +
+ +
+ ); + })} +
+
+
+
+
+
+
+ ); + } + + if (mode === "search") { + return ( + + + { + setMode("default"); + }} + sx={{ + cursor: "pointer", + color: "white", + }} + /> + + + + + + setSearchValue(e.target.value)} + sx={{ ml: 1, flex: 1 }} + placeholder="Search chat text" + inputProps={{ + "aria-label": "Search for apps", + fontSize: "16px", + fontWeight: 400, + }} + /> + + + {searchValue && ( + { + setSearchValue(""); + }} + > + + + )} + + + + + {!!selectedMember && ( + { + setSelectedMember(0); + }} + sx={{ + cursor: "pointer", + color: "white", + }} + /> + )} + + + {debouncedValue && searchedList?.length === 0 && ( + + No results + + )} + +
+
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const index = virtualRow.index; + let message = searchedList[index]; + return ( +
+ + Error loading content: Invalid Data + + } + > + + +
+ ); + })} +
+
+
+
+
+
+
+ ); + } + return ( + + + { + setMode("search"); + }} + > + + + { + setMode("default"); + setSearchValue(""); + setSelectedMember(0); + openQManager(); + }} + > + + + + { + setMode("mentions"); + setSearchValue(""); + setSelectedMember(0); + }} + > + 0 && + (!lastMentionTimestamp || + lastMentionTimestamp < mentionList[0]?.timestamp) + ? "var(--unread)" + : "white", + }} + /> + + + + + ); +}; + +const ShowMessage = ({ message, goToMessage, messages }) => { + return ( + + + + + {message?.senderName?.charAt(0)} + + + {message?.senderName} + + + + + + {formatTimestamp(message.timestamp)} + + { + const findMsgIndex = messages.findIndex( + (item) => item?.signature === message?.signature + ); + if (findMsgIndex !== -1) { + goToMessage(findMsgIndex); + } + }} + > + {message?.messageText && ( + + )} + {message?.decryptedData?.message && ( +

"} + /> + )} +
+
+ ); +}; diff --git a/src/components/Chat/MentionList.tsx b/src/components/Chat/MentionList.tsx new file mode 100644 index 0000000..85f6890 --- /dev/null +++ b/src/components/Chat/MentionList.tsx @@ -0,0 +1,69 @@ +import React, { + forwardRef, useEffect, useImperativeHandle, + useState, + } from 'react' + + export default forwardRef((props, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + const selectItem = index => { + const item = props.items[index] + + if (item) { + props.command(item) + } + } + + const upHandler = () => { + setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [props.items]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter') { + enterHandler() + return true + } + + return false + }, + })) + + return ( +
+ {props.items.length + ? props.items.map((item, index) => ( + + )) + :
No result
+ } +
+ ) + }) + \ No newline at end of file diff --git a/src/components/Chat/MessageDisplay.tsx b/src/components/Chat/MessageDisplay.tsx index df63063..916c7d4 100644 --- a/src/components/Chat/MessageDisplay.tsx +++ b/src/components/Chat/MessageDisplay.tsx @@ -1,15 +1,20 @@ -import React, { useEffect } from "react"; -import DOMPurify from "dompurify"; -import "./styles.css"; -import { executeEvent } from "../../utils/events"; +import React, { useEffect } from 'react'; +import DOMPurify from 'dompurify'; +import './styles.css'; +import { executeEvent } from '../../utils/events'; +import { Embed } from '../Embeds/Embed'; -const extractComponents = (url) => { +export const extractComponents = (url) => { if (!url || !url.startsWith("qortal://")) { - // Check if url exists and starts with "qortal://" return null; } - url = url.replace(/^(qortal\:\/\/)/, ""); // Safe to use replace now + // Skip links starting with "qortal://use-" + if (url.startsWith("qortal://use-")) { + return null; + } + + url = url.replace(/^(qortal\:\/\/)/, ""); if (url.includes("/")) { let parts = url.split("/"); const service = parts[0].toUpperCase(); @@ -26,19 +31,20 @@ const extractComponents = (url) => { function processText(input) { const linkRegex = /(qortal:\/\/\S+)/g; + function processNode(node) { if (node.nodeType === Node.TEXT_NODE) { const parts = node.textContent.split(linkRegex); if (parts.length > 0) { const fragment = document.createDocumentFragment(); parts.forEach((part) => { - if (part.startsWith("qortal://")) { - const link = document.createElement("span"); - link.setAttribute("data-url", part); + if (part.startsWith('qortal://')) { + const link = document.createElement('span'); + link.setAttribute('data-url', part); link.textContent = part; - link.style.color = "var(--code-block-text-color)"; - link.style.textDecoration = "underline"; - link.style.cursor = "pointer"; + link.style.color = 'var(--code-block-text-color)'; + link.style.textDecoration = 'underline'; + link.style.cursor = 'pointer'; fragment.appendChild(link); } else { fragment.appendChild(document.createTextNode(part)); @@ -51,7 +57,7 @@ function processText(input) { } } - const wrapper = document.createElement("div"); + const wrapper = document.createElement('div'); wrapper.innerHTML = input; processNode(wrapper); return wrapper.innerHTML; @@ -60,102 +66,64 @@ function processText(input) { export const MessageDisplay = ({ htmlContent, isReply }) => { const linkify = (text) => { if (!text) return ""; // Return an empty string if text is null or undefined - + let textFormatted = text; const urlPattern = /(\bhttps?:\/\/[^\s<]+|\bwww\.[^\s<]+)/g; textFormatted = text.replace(urlPattern, (url) => { - const href = url.startsWith("http") ? url : `https://${url}`; - return `${DOMPurify.sanitize(url)}`; + const href = url.startsWith('http') ? url : `https://${url}`; + return `${DOMPurify.sanitize(url)}`; }); return processText(textFormatted); }; + const sanitizedContent = DOMPurify.sanitize(linkify(htmlContent), { ALLOWED_TAGS: [ - "a", - "b", - "i", - "em", - "strong", - "p", - "br", - "div", - "span", - "img", - "ul", - "ol", - "li", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "blockquote", - "code", - "pre", - "table", - "thead", - "tbody", - "tr", - "th", - "td", + 'a', 'b', 'i', 'em', 'strong', 'p', 'br', 'div', 'span', 'img', + 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'table', 'thead', 'tbody', 'tr', 'th', 'td' ], ALLOWED_ATTR: [ - "href", - "target", - "rel", - "class", - "src", - "alt", - "title", - "width", - "height", - "style", - "align", - "valign", - "colspan", - "rowspan", - "border", - "cellpadding", - "cellspacing", - "data-url", + 'href', 'target', 'rel', 'class', 'src', 'alt', 'title', + 'width', 'height', 'style', 'align', 'valign', 'colspan', 'rowspan', 'border', 'cellpadding', 'cellspacing', 'data-url' ], - }); + }).replace(/]*data-url="qortal:\/\/use-embed\/[^"]*"[^>]*>.*?<\/span>/g, '');; const handleClick = async (e) => { e.preventDefault(); const target = e.target; - if (target.tagName === "A") { - const href = target.getAttribute("href"); - if (chrome && chrome.tabs) { - chrome.tabs.create({ url: href }, (tab) => { - if (chrome.runtime.lastError) { - console.error("Error opening tab:", chrome.runtime.lastError); - } else { - console.log("Tab opened successfully:", tab); - } - }); - } - } else if (target.getAttribute("data-url")) { - const url = target.getAttribute("data-url"); + if (target.tagName === 'A') { + const href = target.getAttribute('href'); + window.electronAPI.openExternal(href); + } else if (target.getAttribute('data-url')) { + const url = target.getAttribute('data-url'); const res = extractComponents(url); if (res) { const { service, name, identifier, path } = res; executeEvent("addTab", { data: { service, name, identifier, path } }); - executeEvent("open-apps-mode", {}); + executeEvent("open-apps-mode", { }); } } }; + const embedLink = htmlContent?.match(/qortal:\/\/use-embed\/[^\s<>]+/); + + let embedData = null; + + if (embedLink) { + embedData = embedLink[0] + } + return ( + <> + {embedLink && ( + + )}
+ ); }; diff --git a/src/components/Chat/MessageItem.tsx b/src/components/Chat/MessageItem.tsx index c37f7a5..fef930d 100644 --- a/src/components/Chat/MessageItem.tsx +++ b/src/components/Chat/MessageItem.tsx @@ -1,8 +1,8 @@ import { Message } from "@chatscope/chat-ui-kit-react"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { useInView } from "react-intersection-observer"; import { MessageDisplay } from "./MessageDisplay"; -import { Avatar, Box, ButtonBase, Typography } from "@mui/material"; +import { Avatar, Box, Button, ButtonBase, List, ListItem, ListItemText, Popover, Typography } from "@mui/material"; import { formatTimestamp } from "../../utils/time"; import { getBaseApi } from "../../background"; import { getBaseApiReact } from "../../App"; @@ -16,6 +16,10 @@ import ReplyIcon from "@mui/icons-material/Reply"; import { Spacer } from "../../common/Spacer"; import { ReactionPicker } from "../ReactionPicker"; import KeyOffIcon from '@mui/icons-material/KeyOff'; +import EditIcon from '@mui/icons-material/Edit'; +import Mention from "@tiptap/extension-mention"; +import TextStyle from '@tiptap/extension-text-style'; + export const MessageItem = ({ message, onSeen, @@ -30,21 +34,32 @@ export const MessageItem = ({ handleReaction, reactions, isUpdating, - lastSignature + lastSignature, + onEdit, + isPrivate, + setMobileViewModeKeepOpen }) => { + const [anchorEl, setAnchorEl] = useState(null); + const [selectedReaction, setSelectedReaction] = useState(null); const { ref, inView } = useInView({ threshold: 0.7, // Fully visible - triggerOnce: true, // Only trigger once when it becomes visible + triggerOnce: false, // Only trigger once when it becomes visible }); useEffect(() => { - if (inView && message.unread) { + if (inView && isLast && onSeen) { onSeen(message.id); } - }, [inView, message.id, message.unread, onSeen]); + }, [inView, message.id, isLast]); return ( + <> + {message?.divide && ( +
+ Unread messages below +
+ )}
{message?.senderName?.charAt(0)} @@ -122,6 +137,15 @@ export const MessageItem = ({ gap: '10px', alignItems: 'center' }}> + {message?.sender === myAddress && (!message?.isNotEncrypted || isPrivate === false) && ( + { + onEdit(message); + }} + > + + + )} {!isShowingAsReply && ( { @@ -182,13 +206,16 @@ export const MessageItem = ({ StarterKit, Underline, Highlight, + Mention, + TextStyle ])} + setMobileViewModeKeepOpen={setMobileViewModeKeepOpen} /> )} {reply?.decryptedData?.type === "notification" ? ( ) : ( - + )} @@ -200,13 +227,16 @@ export const MessageItem = ({ StarterKit, Underline, Highlight, + Mention, + TextStyle ])} + setMobileViewModeKeepOpen={setMobileViewModeKeepOpen} /> )} {message?.decryptedData?.type === "notification" ? ( ) : ( - + )} { - if(reactions[reaction] && reactions[reaction]?.find((item)=> item?.sender === myAddress)){ - handleReaction(reaction, message, false) - } else { - handleReaction(reaction, message, true) - } - }}> + }} onClick={(event) => { + event.stopPropagation(); // Prevent event bubbling + setAnchorEl(event.currentTarget); + setSelectedReaction(reaction); + }}>
{reaction}
{numberOfReactions > 1 && ( + {selectedReaction && ( + { + setAnchorEl(null); + setSelectedReaction(null); + }} + anchorOrigin={{ + vertical: "top", + horizontal: "center", + }} + transformOrigin={{ + vertical: "bottom", + horizontal: "center", + }} + PaperProps={{ + style: { + backgroundColor: "#232428", + color: "white", + }, + }} + > + + + People who reacted with {selectedReaction} + + + {reactions[selectedReaction]?.map((reactionItem) => ( + + + + ))} + + + + + )} - {message?.isNotEncrypted && ( + {message?.isNotEncrypted && isPrivate && ( ) : ( + <> + {message?.isEdit && ( + + Edited + + )} {formatTimestamp(message.timestamp)} + )}
@@ -305,11 +414,12 @@ export const MessageItem = ({ > */} {/* {!message.unread && Seen} */}
+ ); }; -export const ReplyPreview = ({message})=> { +export const ReplyPreview = ({message, isEdit})=> { return ( { - Replied to {message?.senderName || message?.senderAddress} + {isEdit ? ( + Editing Message + ) : ( + Replied to {message?.senderName || message?.senderAddress} + )} + {message?.messageText && ( )} diff --git a/src/components/Chat/TipTap.tsx b/src/components/Chat/TipTap.tsx index b2c4697..234d66e 100644 --- a/src/components/Chat/TipTap.tsx +++ b/src/components/Chat/TipTap.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState } from "react"; -import { EditorProvider, useCurrentEditor } from "@tiptap/react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { EditorProvider, useCurrentEditor, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { Color } from "@tiptap/extension-color"; import ListItem from "@tiptap/extension-list-item"; @@ -22,10 +22,28 @@ import RedoIcon from "@mui/icons-material/Redo"; import FormatHeadingIcon from "@mui/icons-material/FormatSize"; import DeveloperModeIcon from "@mui/icons-material/DeveloperMode"; import Compressor from "compressorjs"; - +import Mention from '@tiptap/extension-mention'; import ImageResize from "tiptap-extension-resize-image"; // Import the ResizeImage extension import { isMobile } from "../../App"; +import tippy from "tippy.js"; +import "tippy.js/dist/tippy.css"; +import Popover from '@mui/material/Popover'; +import List from '@mui/material/List'; +import ListItemMui from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemText from '@mui/material/ListItemText'; +import { ReactRenderer } from '@tiptap/react' +import MentionList from './MentionList.jsx' +function textMatcher(doc, from) { + const textBeforeCursor = doc.textBetween(0, from, ' ', ' '); + const match = textBeforeCursor.match(/@[\w]*$/); // Match '@' followed by valid characters + if (!match) return null; + + const start = from - match[0].length; + const query = match[0]; + return { start, query }; +} const MenuBar = ({ setEditorRef, isChat }) => { const { editor } = useCurrentEditor(); const fileInputRef = useRef(null); @@ -279,8 +297,10 @@ export default ({ isFocusedParent, overrideMobile, customEditorHeight, + membersWithNames, + enableMentions }) => { - const [isFocused, setIsFocused] = useState(false); + const extensionsFiltered = isChat ? extensions.filter((item) => item?.name !== "image") : extensions; @@ -290,6 +310,32 @@ export default ({ setEditorRef(editorInstance); }; + // const users = [ + // { id: 1, label: 'Alice' }, + // { id: 2, label: 'Bob' }, + // { id: 3, label: 'Charlie' }, + // ]; + + + + const users = useMemo(()=> { + return (membersWithNames || [])?.map((item)=> { + return { + id: item, + label: item + } + }) + }, [membersWithNames]) + + + + + + const usersRef = useRef([]); + useEffect(() => { + usersRef.current = users; // Keep users up-to-date + }, [users]); + const handleFocus = () => { if (!isMobile) return; setIsFocusedParent(true); @@ -302,14 +348,89 @@ export default ({ } }; + const additionalExtensions = useMemo(()=> { + if(!enableMentions) return [] + return [ + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + suggestion: { + items: ({ query }) => { + if (!query) return usersRef?.current; + return usersRef?.current?.filter((user) => + user.label.toLowerCase().includes(query.toLowerCase()) + ); + }, + render: () => { + let popup; // Reference to the Tippy.js instance + let component; + + return { + onStart: props => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }) + + if (!props.clientRect) { + return + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + + onUpdate(props) { + component.updateProps(props) + + if (!props.clientRect) { + return + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }) + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup[0].hide() + + return true + } + + return component.ref?.onKeyDown(props) + }, + + onExit() { + popup[0].destroy() + component.destroy() + }, + } + }, + }, + }) + ] + }, [enableMentions]) + return ( +
) } - extensions={extensionsFiltered} + extensions={[...extensionsFiltered, ...additionalExtensions + ]} content={content} onCreate={({ editor }) => { editor.on("focus", handleFocus); // Listen for focus event @@ -323,10 +444,10 @@ export default ({ attributes: { class: "tiptap-prosemirror", style: - isMobile ? - `overflow: auto; min-height: ${ - customEditorHeight ? "200px" : "0px" - }; max-height:calc(100svh - ${customEditorHeight || "140px"})`: `overflow: auto; max-height: 250px`, + isMobile ? + `overflow: auto; min-height: ${ + customEditorHeight ? "200px" : "0px" + }; max-height:calc(100svh - ${customEditorHeight || "140px"})`: `overflow: auto; max-height: 250px`, }, handleKeyDown(view, event) { if (!disableEnter && event.key === "Enter") { @@ -348,5 +469,7 @@ export default ({ }, }} /> +
+ ); }; diff --git a/src/components/Chat/styles.css b/src/components/Chat/styles.css index 7c65eb0..161eb13 100644 --- a/src/components/Chat/styles.css +++ b/src/components/Chat/styles.css @@ -71,6 +71,7 @@ margin: 1.5rem 0; padding: 0.75rem 1rem; outline: none; + text-wrap: wrap; } .tiptap pre code { @@ -123,3 +124,51 @@ .isReply p { font-size: 12px !important; } + + +.tiptap .mention { + box-decoration-break: clone; + color: lightblue; + padding: 0.1rem 0.3rem; +} + + +.unread-divider { + width: 90%; + color: white; + border-bottom: 1px solid white; + display: flex; + justify-content: center; + border-radius: 2px; +} + +.mention-item { + cursor: pointer; +} + +.dropdown-menu { + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.4rem; + position: relative; + max-height: 200px; + overflow: auto; + + button { + align-items: center; + background-color: transparent; + display: flex; + gap: 0.25rem; + text-align: left; + font-size: 16px; + width: 100%; + border: none; + color: white; + cursor: pointer; + &:hover, + &:hover.is-selected { + background-color: gray; + } + } +} \ No newline at end of file diff --git a/src/components/ContextMenuMentions.tsx b/src/components/ContextMenuMentions.tsx new file mode 100644 index 0000000..7646ca9 --- /dev/null +++ b/src/components/ContextMenuMentions.tsx @@ -0,0 +1,139 @@ +import React, { useState, useRef, useMemo, useEffect } from "react"; +import { + ListItemIcon, + Menu, + MenuItem, + Typography, + styled, +} from "@mui/material"; + +import { executeEvent } from "../utils/events"; + +const CustomStyledMenu = styled(Menu)(({ theme }) => ({ + "& .MuiPaper-root": { + backgroundColor: "#f9f9f9", + borderRadius: "12px", + padding: theme.spacing(1), + boxShadow: "0 5px 15px rgba(0, 0, 0, 0.2)", + }, + "& .MuiMenuItem-root": { + fontSize: "14px", // Smaller font size for the menu item text + color: "#444", + transition: "0.3s background-color", + "&:hover": { + backgroundColor: "#f0f0f0", // Explicit hover state + }, + }, +})); + +export const ContextMenuMentions = ({ + children, + groupId, + getTimestampMention +}) => { + const [menuPosition, setMenuPosition] = useState(null); + const longPressTimeout = useRef(null); + const preventClick = useRef(false); // Flag to prevent click after long-press or right-click + + + + // Handle right-click (context menu) for desktop + const handleContextMenu = (event) => { + event.preventDefault(); + event.stopPropagation(); // Prevent parent click + + // Set flag to prevent any click event after right-click + preventClick.current = true; + + setMenuPosition({ + mouseX: event.clientX, + mouseY: event.clientY, + }); + }; + + // Handle long-press for mobile + const handleTouchStart = (event) => { + longPressTimeout.current = setTimeout(() => { + preventClick.current = true; // Prevent the next click after long-press + event.stopPropagation(); // Prevent parent click + setMenuPosition({ + mouseX: event.touches[0].clientX, + mouseY: event.touches[0].clientY, + }); + }, 500); // Long press duration + }; + + const handleTouchEnd = (event) => { + clearTimeout(longPressTimeout.current); + + if (preventClick.current) { + event.preventDefault(); + event.stopPropagation(); // Prevent synthetic click after long-press + preventClick.current = false; // Reset the flag + } + }; + + + const handleClose = (e) => { + e.preventDefault(); + e.stopPropagation(); + setMenuPosition(null); + }; + + const addTimestamp = ()=> { + + + chrome?.runtime?.sendMessage( + { + action: "addTimestampMention", + payload: { + timestamp: Date.now(), + groupId + } + }, + (response) => { + if (!response?.error) { + getTimestampMention() + } + } + ); + + } + + return ( +
+ {children} + + { + e.stopPropagation(); + }} + > + { + handleClose(e); + addTimestamp() + }} + > + + Unmark + + + +
+ ); +}; diff --git a/src/components/CoreSyncStatus.css b/src/components/CoreSyncStatus.css new file mode 100644 index 0000000..87bf9d7 --- /dev/null +++ b/src/components/CoreSyncStatus.css @@ -0,0 +1,59 @@ + .lineHeight { + line-height: 33%; + } + + .tooltip { + display: inline-block; + position: relative; + text-align: left; + } + + .tooltip .bottom { + min-width: 225px; + max-width: 250px; + top: 35px; + right: 0px; + /* transform: translate(-50%, 0); */ + padding: 10px 10px; + color: var(--black); + background-color: var(--bg-2); + font-weight: normal; + font-size: 13px; + border-radius: 8px; + position: absolute; + z-index: 99999999; + box-sizing: border-box; + box-shadow: 0 1px 8px rgba(0, 0, 0, 0.5); + border: 1px solid var(--black); + visibility: hidden; + opacity: 0; + transition: opacity 0.2s; + } + + .tooltip:hover .bottom { + visibility: visible; + opacity: 1; + z-index: 100; + } + + .tooltip .bottom i { + position: absolute; + bottom: 100%; + left: 50%; + margin-left: -12px; + width: 24px; + height: 12px; + overflow: hidden; + } + + .tooltip .bottom i::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + left: 50%; + transform: translate(-50%, 50%) rotate(45deg); + background-color: var(--white); + border: 1px solid var(--black); + box-shadow: 0 1px 8px rgba(0, 0, 0, 0.5); + } \ No newline at end of file diff --git a/src/components/CoreSyncStatus.tsx b/src/components/CoreSyncStatus.tsx new file mode 100644 index 0000000..641996a --- /dev/null +++ b/src/components/CoreSyncStatus.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react'; +import syncedImg from '../assets/syncStatus/synced.png' +import syncedMintingImg from '../assets/syncStatus/synced_minting.png' +import syncingImg from '../assets/syncStatus/syncing.png' +import { getBaseApiReact } from '../App'; +import './CoreSyncStatus.css' +export const CoreSyncStatus = ({imageSize, position}) => { + const [nodeInfos, setNodeInfos] = useState({}); + const [coreInfos, setCoreInfos] = useState({}); + const [isUsingGateway, setIsUsingGateway] = useState(false); + + useEffect(() => { + const getNodeInfos = async () => { + + + try { + setIsUsingGateway(!!getBaseApiReact()?.includes('ext-node.qortal.link')) + const url = `${getBaseApiReact()}/admin/status`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const data = await response.json(); + setNodeInfos(data); + } catch (error) { + console.error('Request failed', error); + } + }; + + const getCoreInfos = async () => { + + + try { + const url = `${getBaseApiReact()}/admin/info`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const data = await response.json(); + setCoreInfos(data); + } catch (error) { + console.error('Request failed', error); + } + }; + + getNodeInfos(); + getCoreInfos(); + + const interval = setInterval(() => { + getNodeInfos(); + getCoreInfos(); + }, 30000); + + return () => clearInterval(interval); + }, []); + + const renderSyncStatusIcon = () => { + const { isSynchronizing = false, syncPercent = 0, isMintingPossible = false, height = 0, numberOfConnections = 0 } = nodeInfos; + const buildVersion = coreInfos?.buildVersion ? coreInfos?.buildVersion.substring(0, 12) : ''; + + let imagePath = syncingImg; + let message = `Synchronizing` + if (isMintingPossible && !isUsingGateway) { + imagePath = syncedMintingImg; + message = `${isSynchronizing ? 'Synchronizing' : 'Synchronized'} ${'(Minting)'}` + } else if (isSynchronizing === true && syncPercent === 99) { + imagePath = syncingImg + } else if (isSynchronizing && !isMintingPossible && syncPercent === 100) { + imagePath = syncingImg; + message = `Synchronizing ${isUsingGateway ? '' :'(Not Minting)'}` + } else if (!isSynchronizing && !isMintingPossible && syncPercent === 100) { + imagePath = syncedImg + message = `Synchronized ${isUsingGateway ? '' :'(Not Minting)'}` + } else if (isSynchronizing && isMintingPossible && syncPercent === 100) { + imagePath = syncingImg; + message = `Synchronizing ${isUsingGateway ? '' :'(Minting)'}` + } else if (!isSynchronizing && isMintingPossible && syncPercent === 100) { + imagePath = syncedMintingImg; + message = `Synchronized ${isUsingGateway ? '' :'(Minting)'}` + } + + + + return ( +
+ sync status +
+

Core Information

+

Core Version: {buildVersion}

+

{message}

+

Block Height: {height || ''}

+

Connected Peers: {numberOfConnections || ''}

+

Using gateway: {isUsingGateway?.toString()}

+ +
+
+ ); + }; + + return ( +
+ {renderSyncStatusIcon()} +
+ ); +}; + diff --git a/src/components/Desktop/DesktopFooter.tsx b/src/components/Desktop/DesktopFooter.tsx index bbca8a1..06d8f2c 100644 --- a/src/components/Desktop/DesktopFooter.tsx +++ b/src/components/Desktop/DesktopFooter.tsx @@ -78,7 +78,8 @@ export const DesktopFooter = ({ desktopViewMode, hide, setIsOpenSideViewDirects, - setIsOpenSideViewGroups + setIsOpenSideViewGroups, + myName }) => { @@ -178,7 +179,7 @@ export const DesktopFooter = ({ - +
); diff --git a/src/components/Embeds/AttachmentEmbed.tsx b/src/components/Embeds/AttachmentEmbed.tsx new file mode 100644 index 0000000..457162c --- /dev/null +++ b/src/components/Embeds/AttachmentEmbed.tsx @@ -0,0 +1,327 @@ +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { MyContext, getBaseApiReact } from "../../App"; +import { + Card, + CardContent, + CardHeader, + Typography, + RadioGroup, + Radio, + FormControlLabel, + Button, + Box, + ButtonBase, + Divider, + Dialog, + IconButton, + CircularProgress, +} from "@mui/material"; +import { base64ToBlobUrl } from "../../utils/fileReading"; +import { saveFileToDiskGeneric } from "../../utils/generateWallet/generateWallet"; +import AttachmentIcon from '@mui/icons-material/Attachment'; +import RefreshIcon from "@mui/icons-material/Refresh"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import { CustomLoader } from "../../common/CustomLoader"; +import { Spacer } from "../../common/Spacer"; +import { FileAttachmentContainer, FileAttachmentFont } from "./Embed-styles"; +import DownloadIcon from "@mui/icons-material/Download"; +import SaveIcon from '@mui/icons-material/Save'; +import { useSetRecoilState } from "recoil"; +import { decodeIfEncoded } from "../../utils/decode"; + + +export const AttachmentCard = ({ + resourceData, + resourceDetails, + owner, + refresh, + openExternal, + external, + isLoadingParent, + errorMsg, + encryptionType, + selectedGroupId + }) => { + + const [isOpen, setIsOpen] = useState(true); + const { downloadResource } = useContext(MyContext); + + const saveToDisk = async ()=> { + const { name, service, identifier } = resourceData; + + const url = `${getBaseApiReact()}/arbitrary/${service}/${name}/${identifier}`; + fetch(url) + .then(response => response.blob()) + .then(async blob => { + await saveFileToDiskGeneric(blob, resourceData?.fileName) + }) + .catch(error => { + console.error("Error fetching the video:", error); + }); + } + + const saveToDiskEncrypted = async ()=> { + let blobUrl + try { + const { name, service, identifier,key } = resourceData; + + const url = `${getBaseApiReact()}/arbitrary/${service}/${name}/${identifier}?encoding=base64`; + const res = await fetch(url) + const data = await res.text(); + let decryptedData + try { + if(key && encryptionType === 'private'){ + + decryptedData = await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "DECRYPT_DATA_WITH_SHARING_KEY", + type: "qortalRequest", + payload: { + encryptedData: data, + key: decodeURIComponent(key), + }, + }, + (response) => { + if (response.error) { + rej(response?.message); + return; + } else { + res(response); + return + } + } + ); + }); + } + if(encryptionType === 'group'){ + + decryptedData = await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "DECRYPT_QORTAL_GROUP_DATA", + type: "qortalRequest", + payload: { + data64: data, + groupId: selectedGroupId, + }, + }, + (response) => { + if (response.error) { + rej(response?.message); + return; + } else { + res(response); + return + } + } + ); + }); + } + } catch (error) { + throw new Error('Unable to decrypt') + } + + if (!decryptedData || decryptedData?.error) throw new Error("Could not decrypt data"); + blobUrl = base64ToBlobUrl(decryptedData, resourceData?.mimeType) + const response = await fetch(blobUrl); + const blob = await response.blob(); + await saveFileToDiskGeneric(blob, resourceData?.fileName) + + } catch (error) { + console.error(error) + } finally { + if(blobUrl){ + URL.revokeObjectURL(blobUrl); + } + + } + } + return ( + + + + + ATTACHMENT embed + + + + + + {external && ( + + + + )} + + + + + Created by {decodeIfEncoded(owner)} + + + {encryptionType === 'private' ? "ENCRYPTED" : encryptionType === 'group' ? 'GROUP ENCRYPTED' : "Not encrypted"} + + + + + + + {isLoadingParent && isOpen && ( + + {" "} + {" "} + + )} + {errorMsg && ( + + {" "} + + {errorMsg} + {" "} + + )} + + + + + {resourceData?.fileName && ( + <> + {resourceData?.fileName} + + + )} + { + if(resourceDetails?.status?.status === 'READY'){ + if(encryptionType){ + saveToDiskEncrypted() + return + } + saveToDisk() + return + } + + downloadResource(resourceData) + + + }}> + + + {resourceDetails?.status?.status === 'DOWNLOADED' ? 'BUILDING' : resourceDetails?.status?.status} + {!resourceDetails && ( + <> + + Download File + + + )} + {resourceDetails && resourceDetails?.status?.status !== 'READY' && resourceDetails?.status?.status !== 'FAILED_TO_DOWNLOAD' && ( + <> + + Downloading: {resourceDetails?.status?.percentLoaded || '0'}% + + + )} + {resourceDetails && resourceDetails?.status?.status === 'READY' && ( + <> + + Save to Disk + + + )} + + + + + + + + + ); + }; \ No newline at end of file diff --git a/src/components/Embeds/Embed-styles.tsx b/src/components/Embeds/Embed-styles.tsx new file mode 100644 index 0000000..b0b5482 --- /dev/null +++ b/src/components/Embeds/Embed-styles.tsx @@ -0,0 +1,18 @@ +import { Box, Typography, styled } from "@mui/material"; + +export const FileAttachmentContainer = styled(Box)(({ theme }) => ({ + display: "flex", + alignItems: "center", + padding: "5px 10px", + border: `1px solid ${theme.palette.text.primary}`, + width: "100%", + gap: '20px' + })); + + export const FileAttachmentFont = styled(Typography)(({ theme }) => ({ + fontSize: "20px", + letterSpacing: 0, + fontWeight: 400, + userSelect: "none", + whiteSpace: "nowrap", + })); \ No newline at end of file diff --git a/src/components/Embeds/Embed.tsx b/src/components/Embeds/Embed.tsx new file mode 100644 index 0000000..359e3ba --- /dev/null +++ b/src/components/Embeds/Embed.tsx @@ -0,0 +1,406 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { getBaseApiReact } from "../../App"; + + +import { CustomizedSnackbars } from "../Snackbar/Snackbar"; + +import { extractComponents } from "../Chat/MessageDisplay"; +import { executeEvent } from "../../utils/events"; + +import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; +import { blobControllerAtom, blobKeySelector, resourceKeySelector, selectedGroupIdAtom } from "../../atoms/global"; +import { parseQortalLink } from "./embed-utils"; +import { PollCard } from "./PollEmbed"; +import { ImageCard } from "./ImageEmbed"; +import { AttachmentCard } from "./AttachmentEmbed"; +import { base64ToBlobUrl } from "../../utils/fileReading"; + +const getPoll = async (name) => { + const pollName = name; + const url = `${getBaseApiReact()}/polls/${pollName}`; + + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const responseData = await response.json(); + if (responseData?.message?.includes("POLL_NO_EXISTS")) { + throw new Error("POLL_NO_EXISTS"); + } else if (responseData?.pollName) { + const urlVotes = `${getBaseApiReact()}/polls/votes/${pollName}`; + + const responseVotes = await fetch(urlVotes, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const responseDataVotes = await responseVotes.json(); + return { + info: responseData, + votes: responseDataVotes, + }; + } +}; + +export const Embed = ({ embedLink }) => { + const [errorMsg, setErrorMsg] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [poll, setPoll] = useState(null); + const [type, setType] = useState(""); + const hasFetched = useRef(false); + const [openSnack, setOpenSnack] = useState(false); + const [infoSnack, setInfoSnack] = useState(null); + const [external, setExternal] = useState(null); + const [imageUrl, setImageUrl] = useState(""); + const [parsedData, setParsedData] = useState(null); + const setBlobs = useSetRecoilState(blobControllerAtom); + const [selectedGroupId] = useRecoilState(selectedGroupIdAtom) + const resourceData = useMemo(()=> { + const parsedDataOnTheFly = parseQortalLink(embedLink); + if(parsedDataOnTheFly?.service && parsedDataOnTheFly?.name && parsedDataOnTheFly?.identifier){ + return { + service : parsedDataOnTheFly?.service, + name: parsedDataOnTheFly?.name, + identifier: parsedDataOnTheFly?.identifier, + fileName: parsedDataOnTheFly?.fileName ? decodeURIComponent(parsedDataOnTheFly?.fileName) : null, + mimeType: parsedDataOnTheFly?.mimeType ? decodeURIComponent(parsedDataOnTheFly?.mimeType) : null, + key: parsedDataOnTheFly?.key ? decodeURIComponent(parsedDataOnTheFly?.key) : null, + } + } else { + return null + } + }, [embedLink]) + + const keyIdentifier = useMemo(()=> { + + if(resourceData){ + return `${resourceData.service}-${resourceData.name}-${resourceData.identifier}` + } else { + return undefined + } + }, [resourceData]) + const blobUrl = useRecoilValue(blobKeySelector(keyIdentifier)); + + const handlePoll = async (parsedData) => { + try { + setIsLoading(true); + setErrorMsg(""); + setType("POLL"); + if (!parsedData?.name) + throw new Error("Invalid poll embed link. Missing name."); + const pollRes = await getPoll(parsedData.name); + setPoll(pollRes); + + } catch (error) { + setErrorMsg(error?.message || "Invalid embed link"); + } finally { + setIsLoading(false); + } + }; + + const getImage = async ({ identifier, name, service }, key, parsedData) => { + try { + if(blobUrl?.blobUrl){ + return blobUrl?.blobUrl + } + let numberOfTries = 0; + let imageFinalUrl = null; + + const tryToGetImageStatus = async () => { + const urlStatus = `${getBaseApiReact()}/arbitrary/resource/status/${service}/${name}/${identifier}?build=true`; + + const responseStatus = await fetch(urlStatus, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const responseData = await responseStatus.json(); + if (responseData?.status === "READY") { + if (parsedData?.encryptionType) { + const urlData = `${getBaseApiReact()}/arbitrary/${service}/${name}/${identifier}?encoding=base64`; + + const responseData = await fetch(urlData, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const data = await responseData.text(); + if (data) { + let decryptedData + try { + if(key && encryptionType === 'private'){ + + decryptedData = await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "DECRYPT_DATA_WITH_SHARING_KEY", + type: "qortalRequest", + payload: { + encryptedData: data, + key: decodeURIComponent(key), + }, + }, + (response) => { + if (response.error) { + rej(response?.message); + return; + } else { + res(response); + return + } + } + ); + }); + } + if(encryptionType === 'group'){ + + decryptedData = await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "DECRYPT_QORTAL_GROUP_DATA", + type: "qortalRequest", + payload: { + data64: data, + groupId: selectedGroupId, + }, + }, + (response) => { + if (response.error) { + rej(response?.message); + return; + } else { + res(response); + return + } + } + ); + }); + } + } catch (error) { + throw new Error('Unable to decrypt') + } + + if (!decryptedData || decryptedData?.error) throw new Error("Could not decrypt data"); + imageFinalUrl = base64ToBlobUrl(decryptedData, parsedData?.mimeType ? decodeURIComponent(parsedData?.mimeType) : undefined) + setBlobs((prev=> { + return { + ...prev, + [`${service}-${name}-${identifier}`]: { + blobUrl: imageFinalUrl, + timestamp: Date.now() + } + } + })) + } else { + throw new Error('No data for image') + } + + } else { + imageFinalUrl = `${getBaseApiReact()}/arbitrary/${service}/${name}/${identifier}?async=true`; + + // If parsedData is used here, it must be defined somewhere + + } + } + }; + + // Retry logic + while (!imageFinalUrl && numberOfTries < 3) { + await tryToGetImageStatus(); + if (!imageFinalUrl) { + numberOfTries++; + await new Promise((res) => { + setTimeout(() => { + res(null); + }, 5000); + }); + } + } + + if (imageFinalUrl) { + + return imageFinalUrl; + } else { + setErrorMsg( + "Unable to download IMAGE. Please try again later by clicking the refresh button" + ); + return null; + } + } catch (error) { + console.error("Error fetching image:", error); + setErrorMsg( + error?.error || error?.message || "An unexpected error occurred while trying to download the image" + ); + return null; + } + }; + + const handleImage = async (parsedData) => { + try { + setIsLoading(true); + setErrorMsg(""); + if (!parsedData?.name || !parsedData?.service || !parsedData?.identifier) + throw new Error("Invalid image embed link. Missing param."); + let image = await getImage({ + name: parsedData.name, + service: parsedData.service, + identifier: parsedData?.identifier, + }, parsedData?.key, parsedData); + + setImageUrl(image); + + } catch (error) { + setErrorMsg(error?.message || "Invalid embed link"); + } finally { + setIsLoading(false); + } + }; + + + const handleLink = () => { + try { + const parsedData = parseQortalLink(embedLink); + setParsedData(parsedData); + const type = parsedData?.type; + try { + if (parsedData?.ref) { + const res = extractComponents(decodeURIComponent(parsedData.ref)); + if (res?.service && res?.name) { + setExternal(res); + } + } + } catch (error) { + + } + switch (type) { + case "POLL": + { + handlePoll(parsedData); + } + break; + case "IMAGE": + setType("IMAGE"); + + break; + case "ATTACHMENT": + setType("ATTACHMENT"); + + break; + default: + break; + } + } catch (error) { + setErrorMsg(error?.message || "Invalid embed link"); + } + }; + + const fetchImage = () => { + try { + const parsedData = parseQortalLink(embedLink); + handleImage(parsedData); + } catch (error) { + setErrorMsg(error?.message || "Invalid embed link"); + } + }; + + const openExternal = () => { + executeEvent("addTab", { data: external }); + executeEvent("open-apps-mode", {}); + }; + + useEffect(() => { + if (!embedLink || hasFetched.current) return; + handleLink(); + hasFetched.current = true; + }, [embedLink]); + + + + const resourceDetails = useRecoilValue(resourceKeySelector(keyIdentifier)); + + const { parsedType, encryptionType } = useMemo(() => { + let parsedType; + let encryptionType = false; + try { + const parsedDataOnTheFly = parseQortalLink(embedLink); + if (parsedDataOnTheFly?.type) { + parsedType = parsedDataOnTheFly.type; + } + if (parsedDataOnTheFly?.encryptionType) { + encryptionType = parsedDataOnTheFly?.encryptionType + } + } catch (error) {} + return { parsedType, encryptionType }; + }, [embedLink]); + + return ( +
+ {parsedType === "POLL" && ( + + )} + {parsedType === "IMAGE" && ( + + )} + {parsedType === 'ATTACHMENT' && ( + + )} + +
+ ); +}; + + + + + + + + diff --git a/src/components/Embeds/ImageEmbed.tsx b/src/components/Embeds/ImageEmbed.tsx new file mode 100644 index 0000000..f1cc859 --- /dev/null +++ b/src/components/Embeds/ImageEmbed.tsx @@ -0,0 +1,265 @@ +import React, { useEffect, useState } from "react"; +import { + Card, + CardContent, + Typography, + + Box, + ButtonBase, + Divider, + Dialog, + IconButton, + +} from "@mui/material"; + +import RefreshIcon from "@mui/icons-material/Refresh"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import { CustomLoader } from "../../common/CustomLoader"; +import ImageIcon from "@mui/icons-material/Image"; +import CloseIcon from "@mui/icons-material/Close"; +import { decodeIfEncoded } from "../../utils/decode"; + +export const ImageCard = ({ + image, + fetchImage, + owner, + refresh, + openExternal, + external, + isLoadingParent, + errorMsg, + encryptionType, + }) => { + const [isOpen, setIsOpen] = useState(true); + const [height, setHeight] = useState('400px') + useEffect(() => { + if (isOpen) { + fetchImage(); + } + }, [isOpen]); + + // useEffect(()=> { + // if(errorMsg){ + // setHeight('300px') + // } + // }, [errorMsg]) + + return ( + + + + + IMAGE embed + + + + + + {external && ( + + + + )} + + + + + Created by {decodeIfEncoded(owner)} + + + {encryptionType === 'private' ? "ENCRYPTED" : encryptionType === 'group' ? 'GROUP ENCRYPTED' : "Not encrypted"} + + + + + + {isLoadingParent && isOpen && ( + + {" "} + {" "} + + )} + {errorMsg && ( + + {" "} + + {errorMsg} + {" "} + + )} + + + + + + + + + ); + }; + + export function ImageViewer({ src, alt = "" }) { + const [isFullscreen, setIsFullscreen] = useState(false); + + const handleOpenFullscreen = () => setIsFullscreen(true); + const handleCloseFullscreen = () => setIsFullscreen(false); + + return ( + <> + {/* Image in container */} + + {alt} + + + {/* Fullscreen Viewer */} + + + {/* Close Button */} + + + + + {/* Fullscreen Image */} + {alt} + + + + ); + } \ No newline at end of file diff --git a/src/components/Embeds/PollEmbed.tsx b/src/components/Embeds/PollEmbed.tsx new file mode 100644 index 0000000..3da02c3 --- /dev/null +++ b/src/components/Embeds/PollEmbed.tsx @@ -0,0 +1,395 @@ +import React, { useContext, useEffect, useState } from "react"; +import { MyContext } from "../../App"; +import { + Card, + CardContent, + CardHeader, + Typography, + RadioGroup, + Radio, + FormControlLabel, + Button, + Box, + ButtonBase, + Divider, + +} from "@mui/material"; +import { getNameInfo } from "../Group/Group"; +import PollIcon from "@mui/icons-material/Poll"; +import { getFee } from "../../background"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { Spacer } from "../../common/Spacer"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import { CustomLoader } from "../../common/CustomLoader"; + + +export const PollCard = ({ + poll, + setInfoSnack, + setOpenSnack, + refresh, + openExternal, + external, + isLoadingParent, + errorMsg, + }) => { + const [selectedOption, setSelectedOption] = useState(""); + const [ownerName, setOwnerName] = useState(""); + const [showResults, setShowResults] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const { show, userInfo } = useContext(MyContext); + const [isLoadingSubmit, setIsLoadingSubmit] = useState(false); + const handleVote = async () => { + const fee = await getFee("VOTE_ON_POLL"); + + await show({ + message: `Do you accept this VOTE_ON_POLL transaction? POLLS are public!`, + publishFee: fee.fee + " QORT", + }); + setIsLoadingSubmit(true); + + window + .sendMessage( + "voteOnPoll", + { + pollName: poll?.info?.pollName, + optionIndex: +selectedOption, + }, + 60000 + ) + .then((response) => { + setIsLoadingSubmit(false); + if (response.error) { + setInfoSnack({ + type: "error", + message: response?.error || "Unable to vote.", + }); + setOpenSnack(true); + return; + } else { + setInfoSnack({ + type: "success", + message: + "Successfully voted. Please wait a couple minutes for the network to propogate the changes.", + }); + setOpenSnack(true); + } + }) + .catch((error) => { + setIsLoadingSubmit(false); + setInfoSnack({ + type: "error", + message: error?.message || "Unable to vote.", + }); + setOpenSnack(true); + }); + }; + + const getName = async (owner) => { + try { + const res = await getNameInfo(owner); + if (res) { + setOwnerName(res); + } + } catch (error) {} + }; + + useEffect(() => { + if (poll?.info?.owner) { + getName(poll.info.owner); + } + }, [poll?.info?.owner]); + + return ( + + + + + POLL embed + + + + + + {external && ( + + + + )} + + + + + Created by {ownerName || poll?.info?.owner} + + + + + {!isOpen && !errorMsg && ( + <> + + + + )} + {isLoadingParent && isOpen && ( + + {" "} + {" "} + + )} + {errorMsg && ( + + {" "} + + {errorMsg} + {" "} + + )} + + + + + + + Options + + setSelectedOption(e.target.value)} + > + {poll?.info?.pollOptions?.map((option, index) => ( + + } + label={option?.optionName} + sx={{ + "& .MuiFormControlLabel-label": { + fontSize: "14px", + + }, + }} + /> + ))} + + + + + {" "} + {`${poll?.votes?.totalVotes} ${ + poll?.votes?.totalVotes === 1 ? " vote" : " votes" + }`} + + + + + item?.voterPublicKey === userInfo?.publicKey + ) + ? "visible" + : "hidden", + }} + > + You've already voted. + + + {isLoadingSubmit && ( + + Is processing transaction, please wait... + + )} + { + setShowResults((prev) => !prev); + }} + > + {showResults ? "hide " : "show "} results + + + {showResults && } + + + ); + }; + + const PollResults = ({ votes }) => { + const maxVotes = Math.max( + ...votes?.voteCounts?.map((option) => option.voteCount) + ); + const options = votes?.voteCounts; + return ( + + {options + .sort((a, b) => b.voteCount - a.voteCount) // Sort options by votes (highest first) + .map((option, index) => ( + + + + {`${index + 1}. ${option.optionName}`} + + + {option.voteCount} votes + + + + + + + ))} + + ); + }; \ No newline at end of file diff --git a/src/components/Embeds/VideoPlayer.tsx b/src/components/Embeds/VideoPlayer.tsx new file mode 100644 index 0000000..c28bc99 --- /dev/null +++ b/src/components/Embeds/VideoPlayer.tsx @@ -0,0 +1,723 @@ +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react' +import ReactDOM from 'react-dom' +import { Box, IconButton, Slider } from '@mui/material' +import { CircularProgress, Typography } from '@mui/material' +import { Key } from 'ts-key-enum' +import { + PlayArrow, + Pause, + VolumeUp, + Fullscreen, + PictureInPicture, VolumeOff, Calculate +} from '@mui/icons-material' +import { styled } from '@mui/system' +import { Refresh } from '@mui/icons-material' + +import { Menu, MenuItem } from '@mui/material' +import { MoreVert as MoreIcon } from '@mui/icons-material' +import { GlobalContext, getBaseApiReact } from '../../App' +import { resourceKeySelector } from '../../atoms/global' +import { useRecoilValue } from 'recoil' +const VideoContainer = styled(Box)` + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + margin: 0px; + padding: 0px; +` + +const VideoElement = styled('video')` + width: 100%; + height: auto; + max-height: calc(100vh - 150px); + background: rgb(33, 33, 33); +` + +const ControlsContainer = styled(Box)` + position: absolute; + display: flex; + align-items: center; + justify-content: space-between; + bottom: 0; + left: 0; + right: 0; + padding: 8px; + background-color: rgba(0, 0, 0, 0.6); +` + +interface VideoPlayerProps { + src?: string + poster?: string + name?: string + identifier?: string + service?: string + autoplay?: boolean + from?: string | null + customStyle?: any + user?: string +} + +export const VideoPlayer: React.FC = ({ + poster, + name, + identifier, + service, + autoplay = true, + from = null, + customStyle = {}, + node +}) => { + + const keyIdentifier = useMemo(()=> { + + if(name && identifier && service){ + return `${service}-${name}-${identifier}` + } else { + return undefined + } + }, [service, name, identifier]) + const download = useRecoilValue(resourceKeySelector(keyIdentifier)); + const { downloadResource } = useContext(GlobalContext); + + const videoRef = useRef(null) + const [playing, setPlaying] = useState(false) + const [volume, setVolume] = useState(1) + const [mutedVolume, setMutedVolume] = useState(1) + const [isMuted, setIsMuted] = useState(false) + const [progress, setProgress] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [canPlay, setCanPlay] = useState(false) + const [startPlay, setStartPlay] = useState(false) + const [isMobileView, setIsMobileView] = useState(false) + const [playbackRate, setPlaybackRate] = useState(1) + const [anchorEl, setAnchorEl] = useState(null) + const reDownload = useRef(false) + + const resetVideoState = () => { + // Reset all states to their initial values + setPlaying(false); + setVolume(1); + setMutedVolume(1); + setIsMuted(false); + setProgress(0); + setIsLoading(false); + setCanPlay(false); + setStartPlay(false); + setIsMobileView(false); + setPlaybackRate(1); + setAnchorEl(null); + + // Reset refs to their initial values + if (videoRef.current) { + videoRef.current.pause(); // Ensure the video is paused + videoRef.current.currentTime = 0; // Reset video progress + } + reDownload.current = false; + }; + + const src = useMemo(() => { + if(name && identifier && service){ + return `${node || getBaseApiReact()}/arbitrary/${service}/${name}/${identifier}` + } + return '' + }, [service, name, identifier]) + + useEffect(()=> { + resetVideoState() + }, [keyIdentifier]) + const resourceStatus = useMemo(() => { + return download?.status || {} + }, [download]) + + const minSpeed = 0.25; + const maxSpeed = 4.0; + const speedChange = 0.25; + + const updatePlaybackRate = (newSpeed: number) => { + if (videoRef.current) { + if (newSpeed > maxSpeed || newSpeed < minSpeed) + newSpeed = minSpeed + videoRef.current.playbackRate = newSpeed + setPlaybackRate(newSpeed) + } + } + + const increaseSpeed = (wrapOverflow = true) => { + const changedSpeed = playbackRate + speedChange + let newSpeed = wrapOverflow ? changedSpeed : Math.min(changedSpeed, maxSpeed) + + + if (videoRef.current) { + updatePlaybackRate(newSpeed); + } + } + + const decreaseSpeed = () => { + if (videoRef.current) { + updatePlaybackRate(playbackRate - speedChange); + } + } + + + const togglePlay = async () => { + if (!videoRef.current) return + setStartPlay(true) + if (!src || resourceStatus?.status !== 'READY') { + ReactDOM.flushSync(() => { + setIsLoading(true) + }) + getSrc() + } + if (playing) { + videoRef.current.pause() + } else { + videoRef.current.play() + } + setPlaying(!playing) + } + + + const onVolumeChange = (_: any, value: number | number[]) => { + if (!videoRef.current) return + videoRef.current.volume = value as number + setVolume(value as number) + setIsMuted(false) + } + + const onProgressChange = (_: any, value: number | number[]) => { + if (!videoRef.current) return + videoRef.current.currentTime = value as number + setProgress(value as number) + if (!playing) { + videoRef.current.play() + setPlaying(true) + } + } + + const handleEnded = () => { + setPlaying(false) + } + + const updateProgress = () => { + if (!videoRef.current) return + setProgress(videoRef.current.currentTime) + } + + const [isFullscreen, setIsFullscreen] = useState(false) + + const enterFullscreen = () => { + if (!videoRef.current) return + if (videoRef.current.requestFullscreen) { + videoRef.current.requestFullscreen() + } + } + + const exitFullscreen = () => { + if (document.exitFullscreen) { + document.exitFullscreen() + } + } + + const toggleFullscreen = () => { + isFullscreen ? exitFullscreen() : enterFullscreen() + } + + + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement) + } + + document.addEventListener('fullscreenchange', handleFullscreenChange) + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange) + } + }, []) + + + + const handleCanPlay = () => { + setIsLoading(false) + setCanPlay(true) + } + + const getSrc = React.useCallback(async () => { + if (!name || !identifier || !service) return + try { + downloadResource({ + name, + service, + identifier + }) + } catch (error) { + console.error(error) + } + }, [identifier, name, service]) + + + + + function formatTime(seconds: number): string { + seconds = Math.floor(seconds) + let minutes: number | string = Math.floor(seconds / 60) + let hours: number | string = Math.floor(minutes / 60) + + let remainingSeconds: number | string = seconds % 60 + let remainingMinutes: number | string = minutes % 60 + + if (remainingSeconds < 10) { + remainingSeconds = '0' + remainingSeconds + } + + if (remainingMinutes < 10) { + remainingMinutes = '0' + remainingMinutes + } + + if (hours === 0) { + hours = '' + } + else { + hours = hours + ':' + } + + return hours + remainingMinutes + ':' + remainingSeconds + } + + const reloadVideo = () => { + if (!videoRef.current) return + const currentTime = videoRef.current.currentTime + videoRef.current.src = src + videoRef.current.load() + videoRef.current.currentTime = currentTime + if (playing) { + videoRef.current.play() + } + } + + useEffect(() => { + if ( + resourceStatus?.status === 'DOWNLOADED' && + reDownload?.current === false + ) { + getSrc() + reDownload.current = true + } + }, [getSrc, resourceStatus]) + + const handleMenuOpen = (event: any) => { + setAnchorEl(event.currentTarget) + } + + const handleMenuClose = () => { + setAnchorEl(null) + } + + useEffect(() => { + const videoWidth = videoRef?.current?.offsetWidth + if (videoWidth && videoWidth <= 600) { + setIsMobileView(true) + } + }, [canPlay]) + + const getDownloadProgress = (current: number, total: number) => { + const progress = current / total * 100; + return Number.isNaN(progress) ? '' : progress.toFixed(0) + '%' + } + const mute = () => { + setIsMuted(true) + setMutedVolume(volume) + setVolume(0) + if (videoRef.current) videoRef.current.volume = 0 + } + const unMute = () => { + setIsMuted(false) + setVolume(mutedVolume) + if (videoRef.current) videoRef.current.volume = mutedVolume + } + + const toggleMute = () => { + isMuted ? unMute() : mute(); + } + + const changeVolume = (volumeChange: number) => { + if (videoRef.current) { + const minVolume = 0; + const maxVolume = 1; + + + let newVolume = volumeChange + volume + + newVolume = Math.max(newVolume, minVolume) + newVolume = Math.min(newVolume, maxVolume) + + setIsMuted(false) + setMutedVolume(newVolume) + videoRef.current.volume = newVolume + setVolume(newVolume); + } + + } + const setProgressRelative = (secondsChange: number) => { + if (videoRef.current) { + const currentTime = videoRef.current?.currentTime + const minTime = 0 + const maxTime = videoRef.current?.duration || 100 + + let newTime = currentTime + secondsChange; + newTime = Math.max(newTime, minTime) + newTime = Math.min(newTime, maxTime) + videoRef.current.currentTime = newTime; + setProgress(newTime); + } + } + + const setProgressAbsolute = (videoPercent: number) => { + if (videoRef.current) { + videoPercent = Math.min(videoPercent, 100) + videoPercent = Math.max(videoPercent, 0) + const finalTime = videoRef.current?.duration * videoPercent / 100 + videoRef.current.currentTime = finalTime + setProgress(finalTime); + } + } + + + const keyboardShortcutsDown = (e: React.KeyboardEvent) => { + e.preventDefault() + + switch (e.key) { + case Key.Add: increaseSpeed(false); break; + case '+': increaseSpeed(false); break; + case '>': increaseSpeed(false); break; + + case Key.Subtract: decreaseSpeed(); break; + case '-': decreaseSpeed(); break; + case '<': decreaseSpeed(); break; + + case Key.ArrowLeft: { + if (e.shiftKey) setProgressRelative(-300); + else if (e.ctrlKey) setProgressRelative(-60); + else if (e.altKey) setProgressRelative(-10); + else setProgressRelative(-5); + } break; + + case Key.ArrowRight: { + if (e.shiftKey) setProgressRelative(300); + else if (e.ctrlKey) setProgressRelative(60); + else if (e.altKey) setProgressRelative(10); + else setProgressRelative(5); + } break; + + case Key.ArrowDown: changeVolume(-0.05); break; + case Key.ArrowUp: changeVolume(0.05); break; + } + } + + const keyboardShortcutsUp = (e: React.KeyboardEvent) => { + e.preventDefault() + + switch (e.key) { + case ' ': togglePlay(); break; + case 'm': toggleMute(); break; + + case 'f': enterFullscreen(); break; + case Key.Escape: exitFullscreen(); break; + + case '0': setProgressAbsolute(0); break; + case '1': setProgressAbsolute(10); break; + case '2': setProgressAbsolute(20); break; + case '3': setProgressAbsolute(30); break; + case '4': setProgressAbsolute(40); break; + case '5': setProgressAbsolute(50); break; + case '6': setProgressAbsolute(60); break; + case '7': setProgressAbsolute(70); break; + case '8': setProgressAbsolute(80); break; + case '9': setProgressAbsolute(90); break; + } + } + + return ( + + + {isLoading && ( + + + + + {resourceStatus?.status === 'REFETCHING' ? ( + <> + <> + {getDownloadProgress(resourceStatus?.localChunkCount, resourceStatus?.totalChunkCount)} + + + <> Refetching data in 25 seconds + + ) : resourceStatus?.status === 'DOWNLOADED' ? ( + <>Download Completed: building tutorial video... + ) : resourceStatus?.status !== 'READY' ? ( + <> + {getDownloadProgress(resourceStatus?.localChunkCount || 0, resourceStatus?.totalChunkCount || 100)} + + + ) : ( + <>Fetching tutorial from the Qortal Network... + )} + + + + )} + {((!src && !isLoading) || !startPlay) && ( + { + togglePlay() + }} + sx={{ + cursor: 'pointer' + }} + > + + + )} + + + + + {isMobileView && canPlay ? ( + <> + + {playing ? : } + + + + + + + + + + + + + + increaseSpeed()}> + + Speed: {playbackRate}x + + + + + + + + ) : canPlay ? ( + <> + + {playing ? : } + + + + + + + {progress && videoRef.current?.duration && formatTime(progress)}/ + {progress && + videoRef.current?.duration && + formatTime(videoRef.current?.duration)} + + + {isMuted ? : } + + + increaseSpeed()} + > + Speed: {playbackRate}x + + + + + + ) : null} + + + ) +} diff --git a/src/components/Embeds/embed-utils.ts b/src/components/Embeds/embed-utils.ts new file mode 100644 index 0000000..c0fe9b0 --- /dev/null +++ b/src/components/Embeds/embed-utils.ts @@ -0,0 +1,40 @@ +function decodeHTMLEntities(str) { + const txt = document.createElement("textarea"); + txt.innerHTML = str; + return txt.value; + } + + export const parseQortalLink = (link) => { + const prefix = "qortal://use-embed/"; + if (!link.startsWith(prefix)) { + throw new Error("Invalid link format"); + } + + // Decode any HTML entities in the link + link = decodeHTMLEntities(link); + + // Separate the type and query string + const [typePart, queryPart] = link.slice(prefix.length).split("?"); + + // Ensure only the type is parsed + const type = typePart.split("/")[0].toUpperCase(); + + const params = {}; + if (queryPart) { + const queryPairs = queryPart.split("&"); + + queryPairs.forEach((pair) => { + const [key, value] = pair.split("="); + if (key && value) { + const decodedKey = decodeURIComponent(key.trim()); + const decodedValue = value.trim().replace( + /<\/?[^>]+(>|$)/g, + "" // Remove any HTML tags + ); + params[decodedKey] = decodedValue; + } + }); + } + + return { type, ...params }; + }; \ No newline at end of file diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 5561735..2859d51 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -92,6 +92,10 @@ import { Apps } from "../Apps/Apps"; import { AppsNavBar } from "../Apps/AppsNavBar"; import { AppsDesktop } from "../Apps/AppsDesktop"; import { formatEmailDate } from "./QMailMessages"; +import LockIcon from '@mui/icons-material/Lock'; +import NoEncryptionGmailerrorredIcon from '@mui/icons-material/NoEncryptionGmailerrorred'; +import { useSetRecoilState } from "recoil"; +import { selectedGroupIdAtom } from "../../atoms/global"; // let touchStartY = 0; // let disablePullToRefresh = false; @@ -187,6 +191,19 @@ export function validateSecretKey(obj) { return true; } +function areKeysEqual(array1, array2) { + // If lengths differ, the arrays cannot be equal + if (array1?.length !== array2?.length) { + return false; + } + + // Sort both arrays and compare their elements + const sortedArray1 = [...array1].sort(); + const sortedArray2 = [...array2].sort(); + + return sortedArray1.every((key, index) => key === sortedArray2[index]); +} + export const getGroupMembers = async (groupNumber: number) => { // const validApi = await findUsableApi(); @@ -441,6 +458,17 @@ export const Group = ({ const [appsMode, setAppsMode] = useState('home') const [isOpenSideViewDirects, setIsOpenSideViewDirects] = useState(false) const [isOpenSideViewGroups, setIsOpenSideViewGroups] = useState(false) + const [isForceShowCreationKeyPopup, setIsForceShowCreationKeyPopup] = useState(false) + const setSelectedGroupId = useSetRecoilState(selectedGroupIdAtom) + + const [groupsProperties, setGroupsProperties] = useState({}) + + const isPrivate = useMemo(()=> { + if(!selectedGroup?.groupId || !groupsProperties[selectedGroup?.groupId]) return null + if(groupsProperties[selectedGroup?.groupId]?.isOpen === true) return false + if(groupsProperties[selectedGroup?.groupId]?.isOpen === false) return true + return null + }, [selectedGroup]) const toggleSideViewDirects = ()=> { if(isOpenSideViewGroups){ @@ -467,6 +495,8 @@ export const Group = ({ useEffect(() => { selectedGroupRef.current = selectedGroup; + setSelectedGroupId(selectedGroup?.groupId) + }, [selectedGroup]); useEffect(() => { @@ -833,15 +863,63 @@ export const Group = ({ }; + const getAdminsForPublic = async(selectedGroup)=> { + try { + const { names, addresses, both } = + await getGroupAdmins(selectedGroup?.groupId) + setAdmins(addresses); + setAdminsWithNames(both); + } catch (error) { + //error + } + } useEffect(() => { - if (selectedGroup) { - setTriedToFetchSecretKey(false); - getSecretKey(true); + if (selectedGroup && isPrivate !== null) { + if(isPrivate){ + setTriedToFetchSecretKey(false); + getSecretKey(true); + } + getGroupOwner(selectedGroup?.groupId); } - }, [selectedGroup]); + if(isPrivate === false){ + setTriedToFetchSecretKey(true); + getAdminsForPublic(selectedGroup) + + } + }, [selectedGroup, isPrivate]); + + + + + + const getGroupsProperties = useCallback(async(address)=> { + try { + const url = `${getBaseApiReact()}/groups/member/${address}`; + const response = await fetch(url); + if(!response.ok) throw new Error('Cannot get group properties') + let data = await response.json(); + const transformToObject = data.reduce((result, item) => { + + result[item.groupId] = item + return result; + }, {}); + setGroupsProperties(transformToObject) + } catch (error) { + // error + } + }, []) + + + useEffect(()=> { + if(!myAddress) return + if(areKeysEqual(groups?.map((grp)=> grp?.groupId), Object.keys(groupsProperties))){ + } else { + getGroupsProperties(myAddress) + } + }, [groups, myAddress]) @@ -2089,16 +2167,36 @@ export const Group = ({ }} > - - {group.groupName?.charAt(0)} - + display: 'flex', + alignItems: 'center', + justifyContent: 'center' + }}> + + + ): ( + + + + )} + {triedToFetchSecretKey && ( )} - {firstSecretKeyInCreation && + {isPrivate && firstSecretKeyInCreation && triedToFetchSecretKey && !secretKeyPublishDate && (
)} - {!admins.includes(myAddress) && + {isPrivate && !admins.includes(myAddress) && !secretKey && triedToFetchSecretKey ? ( <> @@ -2560,10 +2662,11 @@ export const Group = ({ ) : null} ) : admins.includes(myAddress) && - !secretKey && - triedToFetchSecretKey ? null : !triedToFetchSecretKey ? null : ( + (!secretKey && isPrivate) && + triedToFetchSecretKey ? null : !triedToFetchSecretKey ? null : ( <> - {admins.includes(myAddress) && + {((isPrivate && admins.includes(myAddress) && shouldReEncrypt && triedToFetchSecretKey && !firstSecretKeyInCreation && - !hideCommonKeyPopup && ( + !hideCommonKeyPopup) || isForceShowCreationKeyPopup) && ( { } }; - // const handleCancelInvitation = async (address)=> { - // try { - // const fee = await getFee('CANCEL_GROUP_INVITE') - // await show({ - // message: "Would you like to perform a CANCEL_GROUP_INVITE transaction?" , - // publishFee: fee.fee + ' QORT' - // }) - // setIsLoadingCancelInvite(true) - // await new Promise((res, rej)=> { - // window.sendMessage("cancelInvitationToGroup", { - // groupId, - // qortalAddress: address, - // }) - // .then((response) => { - // if (!response?.error) { - // setInfoSnack({ - // type: "success", - // message: "Successfully canceled invitation. It may take a couple of minutes for the changes to propagate", - // }); - // setOpenSnack(true); - // handlePopoverClose(); - // setIsLoadingCancelInvite(true); - // res(response); - // return; - // } - // setInfoSnack({ - // type: "error", - // message: response?.error, - // }); - // setOpenSnack(true); - // rej(response.error); - // }) - // .catch((error) => { - // setInfoSnack({ - // type: "error", - // message: error.message || "An error occurred", - // }); - // setOpenSnack(true); - // rej(error); - // }); - // }) - // } catch (error) { - - // } finally { - // setIsLoadingCancelInvite(false) - // } - // } const rowRenderer = ({ index, key, parent, style }) => { const promotion = promotions[index]; diff --git a/src/components/Save/Save.tsx b/src/components/Save/Save.tsx index e00bd08..2231f98 100644 --- a/src/components/Save/Save.tsx +++ b/src/components/Save/Save.tsx @@ -1,154 +1,603 @@ -import React, { useContext, useEffect, useMemo, useState } from 'react' -import { useRecoilState, useSetRecoilState } from 'recoil'; -import isEqual from 'lodash/isEqual'; // Import deep comparison utility -import { canSaveSettingToQdnAtom, hasSettingsChangedAtom, oldPinnedAppsAtom, settingsLocalLastUpdatedAtom, settingsQDNLastUpdatedAtom, sortablePinnedAppsAtom } from '../../atoms/global'; -import { ButtonBase } from '@mui/material'; -import { objectToBase64 } from '../../qdn/encryption/group-encryption'; -import { MyContext } from '../../App'; -import { getFee } from '../../background'; -import { CustomizedSnackbars } from '../Snackbar/Snackbar'; -import { SaveIcon } from '../../assets/svgs/SaveIcon'; -import { IconWrapper } from '../Desktop/DesktopFooter'; -export const Save = ({isDesktop}) => { - const [pinnedApps, setPinnedApps] = useRecoilState(sortablePinnedAppsAtom); - const [settingsQdnLastUpdated, setSettingsQdnLastUpdated] = useRecoilState(settingsQDNLastUpdatedAtom); - const [settingsLocalLastUpdated] = useRecoilState(settingsLocalLastUpdatedAtom); - const setHasSettingsChangedAtom = useSetRecoilState(hasSettingsChangedAtom); +import React, { useContext, useEffect, useMemo, useState } from "react"; +import { useRecoilState, useSetRecoilState } from "recoil"; +import isEqual from "lodash/isEqual"; // Import deep comparison utility +import { + canSaveSettingToQdnAtom, + hasSettingsChangedAtom, + isUsingImportExportSettingsAtom, + oldPinnedAppsAtom, + settingsLocalLastUpdatedAtom, + settingsQDNLastUpdatedAtom, + sortablePinnedAppsAtom, +} from "../../atoms/global"; +import { Box, Button, ButtonBase, Popover, Typography } from "@mui/material"; +import { objectToBase64 } from "../../qdn/encryption/group-encryption"; +import { MyContext } from "../../App"; +import { getFee } from "../../background"; +import { CustomizedSnackbars } from "../Snackbar/Snackbar"; +import { SaveIcon } from "../../assets/svgs/SaveIcon"; +import { IconWrapper } from "../Desktop/DesktopFooter"; +import { Spacer } from "../../common/Spacer"; +import { LoadingButton } from "@mui/lab"; +import { saveToLocalStorage } from "../Apps/AppsNavBar"; +import { saveFileToDiskGeneric } from "../../utils/generateWallet/generateWallet"; +import { base64ToUint8Array, uint8ArrayToObject } from "../../backgroundFunctions/encryption"; - const [canSave] = useRecoilState(canSaveSettingToQdnAtom); - const [openSnack, setOpenSnack] = useState(false); - const [isLoading, setIsLoading] = useState(false) + +export const handleImportClick = async () => { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.base64,.txt'; + + // Create a promise to handle file selection and reading synchronously + return await new Promise((resolve, reject) => { + fileInput.onchange = () => { + const file = fileInput.files[0]; + if (!file) { + reject(new Error('No file selected')); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + resolve(e.target.result); // Resolve with the file content + }; + reader.onerror = () => { + reject(new Error('Error reading file')); + }; + + reader.readAsText(file); // Read the file as text (Base64 string) + }; + + // Trigger the file input dialog + fileInput.click(); + }); + +} + +export const Save = ({ isDesktop, disableWidth, myName }) => { + const [pinnedApps, setPinnedApps] = useRecoilState(sortablePinnedAppsAtom); + const [settingsQdnLastUpdated, setSettingsQdnLastUpdated] = useRecoilState( + settingsQDNLastUpdatedAtom + ); + const [settingsLocalLastUpdated] = useRecoilState( + settingsLocalLastUpdatedAtom + ); + const setHasSettingsChangedAtom = useSetRecoilState(hasSettingsChangedAtom); + const [isUsingImportExportSettings, setIsUsingImportExportSettings] = useRecoilState(isUsingImportExportSettingsAtom); + + const [canSave] = useRecoilState(canSaveSettingToQdnAtom); + const [openSnack, setOpenSnack] = useState(false); + const [isLoading, setIsLoading] = useState(false); const [infoSnack, setInfoSnack] = useState(null); - const [oldPinnedApps, setOldPinnedApps] = useRecoilState(oldPinnedAppsAtom) + const [oldPinnedApps, setOldPinnedApps] = useRecoilState(oldPinnedAppsAtom); + const [anchorEl, setAnchorEl] = useState(null); + const { show } = useContext(MyContext); - const { show } = useContext(MyContext); + const hasChanged = useMemo(() => { + const newChanges = { + sortablePinnedApps: pinnedApps.map((item) => { + return { + name: item?.name, + service: item?.service, + }; + }), + }; + const oldChanges = { + sortablePinnedApps: oldPinnedApps.map((item) => { + return { + name: item?.name, + service: item?.service, + }; + }), + }; + if (settingsQdnLastUpdated === -100) return false; + return ( + !isEqual(oldChanges, newChanges) && + settingsQdnLastUpdated < settingsLocalLastUpdated + ); + }, [ + oldPinnedApps, + pinnedApps, + settingsQdnLastUpdated, + settingsLocalLastUpdated, + ]); - const hasChanged = useMemo(()=> { - const newChanges = { + + + useEffect(() => { + setHasSettingsChangedAtom(hasChanged); + }, [hasChanged]); + + const saveToQdn = async ()=> { + try { + setIsLoading(true) + const data64 = await objectToBase64({ sortablePinnedApps: pinnedApps.map((item)=> { return { name: item?.name, service: item?.service } }) - } - const oldChanges = { - sortablePinnedApps: oldPinnedApps.map((item)=> { - return { - name: item?.name, - service: item?.service - } - }) - } - if(settingsQdnLastUpdated === -100) return false - return !isEqual(oldChanges, newChanges) && settingsQdnLastUpdated < settingsLocalLastUpdated - }, [oldPinnedApps, pinnedApps, settingsQdnLastUpdated, settingsLocalLastUpdated]) - - useEffect(()=> { - setHasSettingsChangedAtom(hasChanged) - }, [hasChanged]) - - const saveToQdn = async ()=> { - try { - setIsLoading(true) - const data64 = await objectToBase64({ - sortablePinnedApps: pinnedApps.map((item)=> { - return { - name: item?.name, - service: item?.service + }) + const encryptData = await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "ENCRYPT_DATA", + type: "qortalRequest", + payload: { + data64 + }, + }, + (response) => { + if (response.error) { + rej(response?.message); + return; + } else { + res(response); + } - }) + } + ); + }); + if(encryptData && !encryptData?.error){ + const fee = await getFee('ARBITRARY') + + await show({ + message: "Would you like to publish your settings to QDN (encrypted) ?" , + publishFee: fee.fee + ' QORT' }) - const encryptData = await new Promise((res, rej) => { + const response = await new Promise((res, rej) => { chrome?.runtime?.sendMessage( { - action: "ENCRYPT_DATA", - type: "qortalRequest", + action: "publishOnQDN", payload: { - data64 + data: encryptData, + identifier: "ext_saved_settings", + service: 'DOCUMENT_PRIVATE' }, }, (response) => { - if (response.error) { - rej(response?.message); - return; - } else { + + if (!response?.error) { res(response); - + return } + rej(response.error); } ); }); - if(encryptData && !encryptData?.error){ - const fee = await getFee('ARBITRARY') - - await show({ - message: "Would you like to publish your settings to QDN (encrypted) ?" , - publishFee: fee.fee + ' QORT' - }) - const response = await new Promise((res, rej) => { - chrome?.runtime?.sendMessage( - { - action: "publishOnQDN", - payload: { - data: encryptData, - identifier: "ext_saved_settings", - service: 'DOCUMENT_PRIVATE' - }, - }, - (response) => { - - if (!response?.error) { - res(response); - return - } - rej(response.error); - } - ); + if(response?.identifier){ + setOldPinnedApps(pinnedApps) + setSettingsQdnLastUpdated(Date.now()) + setInfoSnack({ + type: "success", + message: + "Sucessfully published to QDN", }); - if(response?.identifier){ - setOldPinnedApps(pinnedApps) - setSettingsQdnLastUpdated(Date.now()) - setInfoSnack({ - type: "success", - message: - "Sucessfully published to QDN", - }); - setOpenSnack(true); - } + setOpenSnack(true); } - } catch (error) { - setInfoSnack({ - type: "error", - message: - error?.message || "Unable to save to QDN", - }); - setOpenSnack(true); - } finally { - setIsLoading(false) } + } catch (error) { + setInfoSnack({ + type: "error", + message: + error?.message || "Unable to save to QDN", + }); + setOpenSnack(true); + } finally { + setIsLoading(false) } + } + const handlePopupClick = (event) => { + event.stopPropagation(); // Prevent parent onClick from firing + setAnchorEl(event.currentTarget); + }; + + const revertChanges = () => { + setPinnedApps(oldPinnedApps); + saveToLocalStorage("ext_saved_settings", "sortablePinnedApps", null); + setAnchorEl(null) + }; + return ( <> - - {isDesktop ? ( + + {isDesktop ? ( - + disableWidth={disableWidth} + color="rgba(250, 250, 250, 0.5)" + label="Save" + selected={false} + > + - ) : ( - - )} - + ) : ( + + )} - setAnchorEl(null)} // Close popover on click outside + anchorOrigin={{ + vertical: "bottom", + horizontal: "center", + }} + transformOrigin={{ + vertical: "top", + horizontal: "center", + }} + sx={{ + width: "300px", + maxWidth: "90%", + maxHeight: "80%", + overflow: "auto", + }} + > + {isUsingImportExportSettings && ( + + + + You are using the export/import way of saving settings. + + + + + + )} + {!isUsingImportExportSettings && ( + + {!myName ? ( + + + You need a registered Qortal name to save your pinned apps to QDN. + + + ) : ( + <> + {hasChanged && ( + + + You have unsaved changes to your pinned apps. Save them to QDN. + + + + Save to QDN + + + {!isNaN(settingsQdnLastUpdated) && settingsQdnLastUpdated > 0 && ( + <> + + Don't like your current local changes? Would you like to + reset to your saved QDN pinned apps? + + + + Revert to QDN + + + )} + {!isNaN(settingsQdnLastUpdated) && settingsQdnLastUpdated === 0 && ( + <> + + Don't like your current local changes? Would you like to + reset to the default pinned apps? + + + + Revert to default + + + )} + + )} + {!isNaN(settingsQdnLastUpdated) && settingsQdnLastUpdated === -100 && isUsingImportExportSettings !== true && ( + + + The app was unable to download your existing QDN-saved pinned + apps. Would you like to overwrite those changes? + + + + Overwrite to QDN + + + )} + {!hasChanged && ( + + + You currently do not have any changes to your pinned apps + + + + )} + + )} + + + )} + + + { + try { + const fileContent = await handleImportClick(); + const decryptedData = await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "DECRYPT_DATA", + type: "qortalRequest", + payload: { + encryptedData: fileContent + }, + }, + (response) => { + console.log('response', response) + if (response.error) { + rej(response?.message); + return; + } else { + res(response); + return + } + } + ); + }); + const decryptToUnit8ArraySubject = + base64ToUint8Array(decryptedData); + const responseData = uint8ArrayToObject( + decryptToUnit8ArraySubject + ); + console.log('responseData', responseData) + if(Array.isArray(responseData)){ + saveToLocalStorage("ext_saved_settings_import_export", "sortablePinnedApps", responseData, { + isUsingImportExport: true + }); + setPinnedApps(responseData) + setOldPinnedApps(responseData) + setIsUsingImportExportSettings(true) + } + + } catch (error) { + console.log("error", error); + } + }}> + + Import + + { + try { + console.log('pinnedApps', pinnedApps) + const data64 = await objectToBase64(pinnedApps); + + const encryptedData = await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "ENCRYPT_DATA", + type: "qortalRequest", + payload: { + data64 + }, + }, + (response) => { + if (response.error) { + rej(response?.message); + return; + } else { + res(response); + return + } + } + ); + }); + const blob = new Blob([encryptedData], { + type: "text/plain", + }); + + const timestamp = new Date() + .toISOString() + .replace(/:/g, "-"); // Safe timestamp for filenames + const filename = `qortal-new-ui-backup-settings-${timestamp}.txt`; + await saveFileToDiskGeneric(blob, filename) + + } catch (error) { + console.log('error', error) + } + }}> + Export + + + + + { setInfo={setInfoSnack} /> - - ) -} + ); +}; diff --git a/src/components/Tutorials/Tutorials.tsx b/src/components/Tutorials/Tutorials.tsx new file mode 100644 index 0000000..4f9ea49 --- /dev/null +++ b/src/components/Tutorials/Tutorials.tsx @@ -0,0 +1,108 @@ +import React, { useContext, useState } from 'react' +import { GlobalContext, MyContext } from '../../App'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Tab, Tabs, Typography } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { VideoPlayer } from '../Embeds/VideoPlayer'; + +export const Tutorials = () => { + const { openTutorialModal, setOpenTutorialModal } = useContext(GlobalContext); + const [multiNumber, setMultiNumber] = useState(0) + const handleClose = ()=> { + setOpenTutorialModal(null) + setMultiNumber(0) + } + if(!openTutorialModal) return null + if(openTutorialModal?.multi){ + const selectedTutorial = openTutorialModal?.multi[multiNumber] + return ( + + setMultiNumber(value)} aria-label="basic tabs example"> + {openTutorialModal?.multi?.map((item, index)=> { + return ( + + + ) + })} + + + {selectedTutorial?.title} {` Tutorial`} + + ({ + position: 'absolute', + right: 8, + top: 8, + color: theme.palette.grey[500], + })} + > + + + + + + + + + + + ) + } + return ( + <> + + + {openTutorialModal?.title} {` Tutorial`} + + ({ + position: 'absolute', + right: 8, + top: 8, + color: theme.palette.grey[500], + })} + > + + + + + + + + + + + + ) +} diff --git a/src/components/Tutorials/useHandleTutorials.tsx b/src/components/Tutorials/useHandleTutorials.tsx new file mode 100644 index 0000000..9d9445e --- /dev/null +++ b/src/components/Tutorials/useHandleTutorials.tsx @@ -0,0 +1,169 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { saveToLocalStorage } from "../Apps/AppsNavBar"; + + +const checkIfGatewayIsOnline = async () => { + try { + const url = `https://ext-node.qortal.link/admin/status`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const data = await response.json(); + if (data?.height) { + return true + } + return false + + } catch (error) { + return false + + } + } +export const useHandleTutorials = () => { + const [openTutorialModal, setOpenTutorialModal] = useState(null); +const [shownTutorials, setShowTutorials] = useState(null) + +useEffect(()=> { + try { + const storedData = localStorage.getItem('shown-tutorials'); + + + if (storedData) { + setShowTutorials(JSON.parse(storedData)); + } else { + setShowTutorials({}) + } + } catch (error) { + //error + } +}, []) + + const saveShowTutorial = useCallback((type)=> { + try { + + setShowTutorials((prev)=> { + return { + ...(prev || {}), + [type]: true + } + }) + saveToLocalStorage('shown-tutorials', type, true) + } catch (error) { + //error + } + }, []) + const showTutorial = useCallback(async (type, isForce) => { + try { + const isOnline = await checkIfGatewayIsOnline() + if(!isOnline) return + switch (type) { + case "create-account": + { + if((shownTutorials || {})['create-account'] && !isForce) return + saveShowTutorial('create-account') + setOpenTutorialModal({ + title: "Account Creation", + resource: { + name: "a-test", + service: "VIDEO", + identifier: "account-creation-hub", + }, + }); + } + break; + case "important-information": + { + if((shownTutorials || {})['important-information'] && !isForce) return + saveShowTutorial('important-information') + + setOpenTutorialModal({ + title: "Important Information!", + resource: { + name: "a-test", + service: "VIDEO", + identifier: "important-information-hub", + }, + }); + } + break; + case "getting-started": + { + if((shownTutorials || {})['getting-started'] && !isForce) return + saveShowTutorial('getting-started') + + setOpenTutorialModal({ + multi: [ + + { + title: "1. Getting Started", + resource: { + name: "a-test", + service: "VIDEO", + identifier: "getting-started-hub", + }, + }, + { + title: "2. Overview", + resource: { + name: "a-test", + service: "VIDEO", + identifier: "overview-hub", + }, + }, + { + title: "3. Qortal Groups", + resource: { + name: "a-test", + service: "VIDEO", + identifier: "groups-hub", + }, + }, + ], + }); + } + break; + case "qapps": + { + if((shownTutorials || {})['qapps'] && !isForce) return + saveShowTutorial('qapps') + + setOpenTutorialModal({ + multi: [ + { + title: "1. Apps Dashboard", + resource: { + name: "a-test", + service: "VIDEO", + identifier: "apps-dashboard-hub", + }, + }, + { + title: "2. Apps Navigation", + resource: { + name: "a-test", + service: "VIDEO", + identifier: "apps-navigation-hub", + }, + } + + ], + }); + } + break; + default: + break; + } + } catch (error) { + //error + } + }, [shownTutorials]); + return { + showTutorial, + openTutorialModal, + setOpenTutorialModal, + shownTutorialsInitiated: !!shownTutorials + }; +}; diff --git a/src/index.css b/src/index.css index aaa8836..01919fc 100644 --- a/src/index.css +++ b/src/index.css @@ -35,7 +35,10 @@ --bg-2: #27282c; --bg-3: rgba(0, 0, 0, 0.1); --unread: #B14646; - --apps-circle: #1F2023 + --apps-circle: #1F2023; + --green: #5EB049; + --danger: #B14646; + } body { @@ -73,17 +76,43 @@ body { } ::-webkit-scrollbar { - width: 16px; + width: 14px; height: 10px; } ::-webkit-scrollbar-thumb { - background-color: white; + background-color: #444444;; border-radius: 8px; background-clip: content-box; border: 4px solid transparent; } +@property --var1 { + syntax: ""; + inherits: true; + initial-value: transparent; +} + + +.scrollable-container { + transition: --var1 0.4s; + +} + +.scrollable-container:hover { + --var1: #444444; +} + + +.scrollable-container::-webkit-scrollbar-thumb { + background-color: var(--var1); + border-radius: 8px; + background-clip: content-box; + border: 4px solid transparent; + opacity: 0; +} + + /* Mobile-specific scrollbar styles */ @media only screen and (max-width: 600px) { ::-webkit-scrollbar { diff --git a/src/qdn/encryption/group-encryption.ts b/src/qdn/encryption/group-encryption.ts index 4f61dd8..12c9310 100644 --- a/src/qdn/encryption/group-encryption.ts +++ b/src/qdn/encryption/group-encryption.ts @@ -137,7 +137,7 @@ export const encryptDataGroup = ({ data64, publicKeys, privateKey, userPublicKey } } -export const encryptSingle = async ({ data64, secretKeyObject, typeNumber = 1 }: any) => { +export const encryptSingle = async ({ data64, secretKeyObject, typeNumber = 2 }: any) => { // Find the highest key in the secretKeyObject const highestKey = Math.max(...Object.keys(secretKeyObject).filter(item => !isNaN(+item)).map(Number)); const highestKeyObject = secretKeyObject[highestKey]; @@ -180,26 +180,42 @@ export const encryptSingle = async ({ data64, secretKeyObject, typeNumber = 1 }: // Concatenate the highest key, type number, nonce, and encrypted data (new format) const highestKeyStr = highestKey.toString().padStart(10, '0'); // Fixed length of 10 digits - finalEncryptedData = btoa(highestKeyStr + typeNumberStr + nonceBase64 + encryptedDataBase64); + + const highestKeyBytes = new TextEncoder().encode(highestKeyStr.padStart(10, '0')); +const typeNumberBytes = new TextEncoder().encode(typeNumberStr.padStart(3, '0')); + +// Step 3: Concatenate all binary +const combinedBinary = new Uint8Array( + highestKeyBytes.length + typeNumberBytes.length + nonce.length + encryptedData.length +); + // finalEncryptedData = btoa(highestKeyStr) + btoa(typeNumberStr) + nonceBase64 + encryptedDataBase64; + combinedBinary.set(highestKeyBytes, 0); +combinedBinary.set(typeNumberBytes, highestKeyBytes.length); +combinedBinary.set(nonce, highestKeyBytes.length + typeNumberBytes.length); +combinedBinary.set(encryptedData, highestKeyBytes.length + typeNumberBytes.length + nonce.length); + +// Step 4: Base64 encode once + finalEncryptedData = uint8ArrayToBase64(combinedBinary); } return finalEncryptedData; }; -export const decodeBase64ForUIChatMessages = (messages)=> { + + export const decodeBase64ForUIChatMessages = (messages)=> { let msgs = [] for(const msg of messages){ try { const decoded = atob(msg?.data); - const parseDecoded = JSON.parse(decoded) - if(parseDecoded?.messageText){ + const parseDecoded =JSON.parse(decodeURIComponent(escape(decoded))) + msgs.push({ ...msg, ...parseDecoded }) - } + } catch (error) { } @@ -207,9 +223,8 @@ export const decodeBase64ForUIChatMessages = (messages)=> { return msgs } - - export const decryptSingle = async ({ data64, secretKeyObject, skipDecodeBase64 }: any) => { +export const decryptSingle = async ({ data64, secretKeyObject, skipDecodeBase64 }: any) => { // First, decode the base64-encoded input (if skipDecodeBase64 is not set) const decodedData = skipDecodeBase64 ? data64 : atob(data64); @@ -241,6 +256,28 @@ export const decodeBase64ForUIChatMessages = (messages)=> { encryptedDataBase64 = decodeForNumber.slice(10); // The remaining part is the encrypted data } else { if (hasTypeNumber) { + // const typeNumberStr = new TextDecoder().decode(typeNumberBytes); + if(decodeForNumber.slice(10, 13) !== '001'){ + const decodedBinary = base64ToUint8Array(decodedData); + const highestKeyBytes = decodedBinary.slice(0, 10); // if ASCII digits only + const highestKeyStr = new TextDecoder().decode(highestKeyBytes); + +const nonce = decodedBinary.slice(13, 13 + 24); +const encryptedData = decodedBinary.slice(13 + 24); +const highestKey = parseInt(highestKeyStr, 10); + +const messageKey = base64ToUint8Array(secretKeyObject[+highestKey].messageKey); +const decryptedBytes = nacl.secretbox.open(encryptedData, nonce, messageKey); + + // Check if decryption was successful + if (!decryptedBytes) { + throw new Error("Decryption failed"); + } + + // Convert the decrypted Uint8Array back to a Base64 string + return uint8ArrayToBase64(decryptedBytes); + + } // New format: Extract type number and nonce typeNumberStr = possibleTypeNumberStr; // Extract type number nonceBase64 = decodeForNumber.slice(13, 45); // Extract nonce (next 32 characters after type number) @@ -275,6 +312,7 @@ export const decodeBase64ForUIChatMessages = (messages)=> { + export function decryptGroupDataQortalRequest(data64EncryptedData, privateKey) { const allCombined = base64ToUint8Array(data64EncryptedData) const str = "qortalGroupEncryptedData" @@ -420,4 +458,44 @@ export function decryptDeprecatedSingle(uint8Array, publicKey, privateKey) { throw new Error("Unable to decrypt") } return uint8ArrayToBase64(_decryptedData) -} \ No newline at end of file +} + +export const decryptGroupEncryptionWithSharingKey = async ({ data64EncryptedData, key }: any) => { + + const allCombined = base64ToUint8Array(data64EncryptedData) + const str = "qortalGroupEncryptedData" + const strEncoder = new TextEncoder() + const strUint8Array = strEncoder.encode(str) + // Extract the nonce + const nonceStartPosition = strUint8Array.length + const nonceEndPosition = nonceStartPosition + 24 // Nonce is 24 bytes + const nonce = allCombined.slice(nonceStartPosition, nonceEndPosition) + // Extract the shared keyNonce + const keyNonceStartPosition = nonceEndPosition + const keyNonceEndPosition = keyNonceStartPosition + 24 // Nonce is 24 bytes + const keyNonce = allCombined.slice(keyNonceStartPosition, keyNonceEndPosition) + // Extract the sender's public key + const senderPublicKeyStartPosition = keyNonceEndPosition + const senderPublicKeyEndPosition = senderPublicKeyStartPosition + 32 // Public keys are 32 bytes + + // Calculate count first + const countStartPosition = allCombined.length - 4 // 4 bytes before the end, since count is stored in Uint32 (4 bytes) + const countArray = allCombined.slice(countStartPosition, countStartPosition + 4) + const count = new Uint32Array(countArray.buffer)[0] + // Then use count to calculate encryptedData + const encryptedDataStartPosition = senderPublicKeyEndPosition // start position of encryptedData + const encryptedDataEndPosition = allCombined.length - ((count * (32 + 16)) + 4) + const encryptedData = allCombined.slice(encryptedDataStartPosition, encryptedDataEndPosition) + const symmetricKey = base64ToUint8Array(key); + + // Decrypt the data using the nonce and messageKey + const decryptedData = nacl.secretbox.open(encryptedData, nonce, symmetricKey) + + + // Check if decryption was successful + if (!decryptedData) { + throw new Error("Decryption failed"); + } + // Convert the decrypted Uint8Array back to a Base64 string + return uint8ArrayToBase64(decryptedData); + }; \ No newline at end of file diff --git a/src/qortalRequests.ts b/src/qortalRequests.ts index 14c5f91..be53e9e 100644 --- a/src/qortalRequests.ts +++ b/src/qortalRequests.ts @@ -1,5 +1,5 @@ -import { getApiKeyFromStorage } from "./background"; -import { addForeignServer, addListItems, adminAction, cancelSellOrder, createBuyOrder, createPoll, createSellOrder, decryptData, deleteListItems, deployAt, encryptData, getCrossChainServerInfo, getDaySummary, getForeignFee, getListItems, getServerConnectionHistory, getTxActivitySummary, getUserAccount, getUserWallet, getUserWalletInfo, getWalletBalance, joinGroup, publishMultipleQDNResources, publishQDNResource, removeForeignServer, saveFile, sendChatMessage, sendCoin, setCurrentForeignServer, updateForeignFee, voteOnPoll } from "./qortalRequests/get"; +import { gateways, getApiKeyFromStorage } from "./background"; +import { addForeignServer, addListItems, adminAction, cancelSellOrder, createBuyOrder, createPoll, createSellOrder, decryptData, decryptDataWithSharingKey, decryptQortalGroupData, deleteListItems, deployAt, encryptData, encryptDataWithSharingKey, encryptQortalGroupData, getCrossChainServerInfo, getDaySummary, getForeignFee, getListItems, getServerConnectionHistory, getTxActivitySummary, getUserAccount, getUserWallet, getUserWalletInfo, getWalletBalance, joinGroup, publishMultipleQDNResources, publishQDNResource, removeForeignServer, saveFile, sendChatMessage, sendCoin, setCurrentForeignServer, signTransaction, updateForeignFee, voteOnPoll } from "./qortalRequests/get"; @@ -480,6 +480,8 @@ chrome?.runtime?.onMessage.addListener((request, sender, sendResponse) => { } case "ADMIN_ACTION": { + const data = request.payload; + adminAction(data, isFromExtension).then((res) => { sendResponse(res); }) @@ -489,6 +491,69 @@ chrome?.runtime?.onMessage.addListener((request, sender, sendResponse) => { break; } + + case "SIGN_TRANSACTION": { + const data = request.payload; + + signTransaction(data, isFromExtension).then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error?.message }); + }); + + break; + } + + case "DECRYPT_QORTAL_GROUP_DATA": { + const data = request.payload; + + decryptQortalGroupData(data, isFromExtension).then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error?.message }); + }); + + break; + } + + case "ENCRYPT_DATA_WITH_SHARING_KEY": { + const data = request.payload; + + encryptDataWithSharingKey(data, isFromExtension).then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error?.message }); + }); + + break; + } + case "DECRYPT_DATA_WITH_SHARING_KEY": { + const data = request.payload; + + decryptDataWithSharingKey(data, isFromExtension).then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error?.message }); + }); + + break; + } + case "ENCRYPT_QORTAL_GROUP_DATA": { + const data = request.payload; + + encryptQortalGroupData(data, isFromExtension).then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error?.message }); + }); + + break; + } } } return true; diff --git a/src/qortalRequests/get.ts b/src/qortalRequests/get.ts index 33f9b17..404a136 100644 --- a/src/qortalRequests/get.ts +++ b/src/qortalRequests/get.ts @@ -13,16 +13,24 @@ import { sendQortFee, sendCoin as sendCoinFunc, isUsingLocal, - createBuyOrderTxQortalRequest + createBuyOrderTxQortalRequest, + groupSecretkeys, + getBaseApi, + getArbitraryEndpoint } from "../background"; -import { getNameInfo } from "../backgroundFunctions/encryption"; +import { decryptGroupEncryption, getNameInfo, uint8ArrayToObject } from "../backgroundFunctions/encryption"; import { QORT_DECIMALS } from "../constants/constants"; import Base58 from "../deps/Base58"; import { base64ToUint8Array, + createSymmetricKeyAndNonce, decryptDeprecatedSingle, decryptGroupDataQortalRequest, + decryptGroupEncryptionWithSharingKey, + decryptSingle, encryptDataGroup, + encryptSingle, + objectToBase64, uint8ArrayStartsWith, uint8ArrayToBase64, } from "../qdn/encryption/group-encryption"; @@ -48,6 +56,7 @@ const sellerForeignFee = { } + function roundUpToDecimals(number, decimals = 8) { const factor = Math.pow(10, decimals); // Create a factor based on the number of decimals return Math.ceil(+number * factor) / factor; @@ -97,6 +106,139 @@ const _createPoll = async ({pollName, pollDescription, options}, isFromExtension } }; +function validateSecretKey(obj) { + // Check if the input is an object + if (typeof obj !== "object" || obj === null) { + return false; + } + + // Iterate over each key in the object + for (let key in obj) { + // Ensure the key is a string representation of a positive integer + if (!/^\d+$/.test(key)) { + return false; + } + + // Get the corresponding value for the key + const value = obj[key]; + + // Check that value is an object and not null + if (typeof value !== "object" || value === null) { + return false; + } + + // Check for messageKey + if (!value.hasOwnProperty("messageKey")) { + return false; + } + + // Ensure messageKey and nonce are non-empty strings + if ( + typeof value.messageKey !== "string" || + value.messageKey.trim() === "" + ) { + return false; + } + } + + // If all checks passed, return true + return true; +} + +const getPublishesFromAdminsAdminSpace = async ( + admins: string[], + groupId +) => { + const queryString = admins.map((name) => `name=${name}`).join("&"); + const baseUrl = await getBaseApi() + const url = `${baseUrl}/arbitrary/resources/searchsimple?mode=ALL&service=DOCUMENT_PRIVATE&identifier=admins-symmetric-qchat-group-${groupId}&exactmatchnames=true&limit=0&reverse=true&${queryString}&prefix=true`; + const response = await fetch(url); + if (!response.ok) { + throw new Error("network error"); + } + const adminData = await response.json(); + + const filterId = adminData.filter( + (data: any) => data.identifier === `admins-symmetric-qchat-group-${groupId}` + ); + if (filterId?.length === 0) { + return false; + } + const sortedData = filterId.sort((a: any, b: any) => { + // Get the most recent date for both a and b + const dateA = a.updated ? new Date(a.updated) : new Date(a.created); + const dateB = b.updated ? new Date(b.updated) : new Date(b.created); + + // Sort by most recent + return dateB.getTime() - dateA.getTime(); + }); + + return sortedData[0]; +}; + + const getPublishesFromAdmins = async (admins: string[], groupId) => { + const baseUrl = await getBaseApi() + + const queryString = admins.map((name) => `name=${name}`).join("&"); + const url = `${baseUrl}/arbitrary/resources/searchsimple?mode=ALL&service=DOCUMENT_PRIVATE&identifier=symmetric-qchat-group-${ + groupId + }&exactmatchnames=true&limit=0&reverse=true&${queryString}&prefix=true`; + const response = await fetch(url); + if (!response.ok) { + throw new Error("network error"); + } + const adminData = await response.json(); + + const filterId = adminData.filter( + (data: any) => + data.identifier === `symmetric-qchat-group-${groupId}` + ); + if (filterId?.length === 0) { + return false; + } + const sortedData = filterId.sort((a: any, b: any) => { + // Get the most recent date for both a and b + const dateA = a.updated ? new Date(a.updated) : new Date(a.created); + const dateB = b.updated ? new Date(b.updated) : new Date(b.created); + + // Sort by most recent + return dateB.getTime() - dateA.getTime(); + }); + + + return sortedData[0]; +}; + + const getGroupAdmins = async (groupNumber: number) => { + // const validApi = await findUsableApi(); + const baseUrl = await getBaseApi() + + const response = await fetch( + `${baseUrl}/groups/members/${groupNumber}?limit=0&onlyAdmins=true` + ); + const groupData = await response.json(); + let members: any = []; + let membersAddresses = []; + let both = []; + + + const getMemNames = groupData?.members?.map(async (member) => { + if (member?.member) { + const name = await getNameInfo(member.member); + if (name) { + members.push(name); + both.push({ name, address: member.member }); + } + membersAddresses.push(member.member); + } + + return true; + }); + await Promise.all(getMemNames); +console.log('members', members) + return { names: members, addresses: membersAddresses, both }; +}; + const _deployAt = async ( {name, description, @@ -2824,46 +2966,102 @@ export const cancelSellOrder = async (data, isFromExtension) => { }; export const adminAction = async (data, isFromExtension) => { - const requiredFields = [ - "type", - ]; + const requiredFields = ["type"]; const missingFields: string[] = []; requiredFields.forEach((field) => { if (!data[field]) { missingFields.push(field); } }); + // For actions that require a value, check for 'value' field + const actionsRequiringValue = [ + "addpeer", + "removepeer", + "forcesync", + "addmintingaccount", + "removemintingaccount", + ]; + if ( + actionsRequiringValue.includes(data.type.toLowerCase()) && + !data.value + ) { + missingFields.push("value"); + } if (missingFields.length > 0) { const missingFieldsString = missingFields.join(", "); const errorMsg = `Missing fields: ${missingFieldsString}`; throw new Error(errorMsg); } - const isGateway = await isRunningGateway() - if(isGateway){ - throw new Error('This action cannot be done through a gateway') + const isGateway = await isRunningGateway(); + if (isGateway) { + throw new Error("This action cannot be done through a gateway"); } - let apiEndpoint = ''; - switch (data.type.toLowerCase()) { - case 'stop': - apiEndpoint = await createEndpoint('/admin/stop'); - break; - case 'restart': - apiEndpoint = await createEndpoint('/admin/restart'); - break; - case 'bootstrap': - apiEndpoint = await createEndpoint('/admin/bootstrap'); - break; - default: - throw new Error(`Unknown admin action type: ${data.type}`); - } + let apiEndpoint = ""; + let method = "GET"; // Default method + let includeValueInBody = false; + switch (data.type.toLowerCase()) { + case "stop": + apiEndpoint = await createEndpoint("/admin/stop"); + break; + case "restart": + apiEndpoint = await createEndpoint("/admin/restart"); + break; + case "bootstrap": + apiEndpoint = await createEndpoint("/admin/bootstrap"); + break; + case "addmintingaccount": + apiEndpoint = await createEndpoint("/admin/mintingaccounts"); + method = "POST"; + includeValueInBody = true; + break; + case "removemintingaccount": + apiEndpoint = await createEndpoint("/admin/mintingaccounts"); + method = "DELETE"; + includeValueInBody = true; + break; + case "forcesync": + apiEndpoint = await createEndpoint("/admin/forcesync"); + method = "POST"; + includeValueInBody = true; + break; + case "addpeer": + apiEndpoint = await createEndpoint("/peers"); + method = "POST"; + includeValueInBody = true; + break; + case "removepeer": + apiEndpoint = await createEndpoint("/peers"); + method = "DELETE"; + includeValueInBody = true; + break; + default: + throw new Error(`Unknown admin action type: ${data.type}`); + } + // Prepare the permission prompt text + let permissionText = `Do you give this application permission to perform the admin action: ${data.type}`; + if (data.value) { + permissionText += ` with value: ${data.value}`; + } - const resPermission = await getUserPermission({ - text1: `Do you give this application permission to perform a node ${data.type}?`, - }, isFromExtension); - const { accepted } = resPermission; - if (accepted) { - const response = await fetch(apiEndpoint); + const resPermission = await getUserPermission( + { + text1: permissionText, + }, + isFromExtension + ); + const { accepted } = resPermission; + if (accepted) { + // Set up options for the API call + const options: RequestInit = { + method: method, + headers: {}, + }; + if (includeValueInBody) { + options.headers["Content-Type"] = "text/plain"; + options.body = data.value; + } + const response = await fetch(apiEndpoint, options); if (!response.ok) throw new Error("Failed to perform request"); let res; @@ -2876,5 +3074,325 @@ export const adminAction = async (data, isFromExtension) => { } else { throw new Error("User declined request"); } +}; + +export const signTransaction = async (data, isFromExtension) => { + const requiredFields = ["unsignedBytes"]; + const missingFields: string[] = []; + requiredFields.forEach((field) => { + if (!data[field]) { + missingFields.push(field); + } + }); + if (missingFields.length > 0) { + const missingFieldsString = missingFields.join(", "); + const errorMsg = `Missing fields: ${missingFieldsString}`; + throw new Error(errorMsg); + } + + let _url = await createEndpoint( + "/transactions/decode?ignoreValidityChecks=false" + ); + + let _body = data.unsignedBytes; + const response = await fetch(_url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: _body, + }); + if (!response.ok) throw new Error("Failed to decode transaction"); + const decodedData = await response.json(); + const resPermission = await getUserPermission( + { + text1: `Do you give this application permission to sign a transaction?`, + highlightedText: "Read the transaction carefully before accepting!", + text2: `Tx type: ${decodedData.type}`, + json: decodedData, + }, + isFromExtension + ); + const { accepted } = resPermission; + if (accepted) { + + let urlConverted = await createEndpoint("/transactions/convert"); + + const responseConverted = await fetch(urlConverted, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: data.unsignedBytes, + }); + const resKeyPair = await getKeyPair(); + const parsedData = resKeyPair; + const uint8PrivateKey = Base58.decode(parsedData.privateKey); + const uint8PublicKey = Base58.decode(parsedData.publicKey); + const keyPair = { + privateKey: uint8PrivateKey, + publicKey: uint8PublicKey, + }; + const convertedBytes = await responseConverted.text(); + const txBytes = Base58.decode(data.unsignedBytes); + const _arbitraryBytesBuffer = Object.keys(txBytes).map(function (key) { + return txBytes[key]; + }); + const arbitraryBytesBuffer = new Uint8Array(_arbitraryBytesBuffer); + const txByteSigned = Base58.decode(convertedBytes); + const _bytesForSigningBuffer = Object.keys(txByteSigned).map(function ( + key + ) { + return txByteSigned[key]; + }); + const bytesForSigningBuffer = new Uint8Array(_bytesForSigningBuffer); + const signature = nacl.sign.detached( + bytesForSigningBuffer, + keyPair.privateKey + ); + const signedBytes = utils.appendBuffer(arbitraryBytesBuffer, signature); + return uint8ArrayToBase64(signedBytes); + + } else { + throw new Error("User declined request"); + } +}; + + +export const decryptQortalGroupData = async (data, sender) => { + console.log('data', data) + let data64 = data.data64; + let groupId = data?.groupId + let isAdmins = data?.isAdmins + if(!groupId){ + throw new Error('Please provide a groupId') + } + + if (!data64) { + throw new Error("Please include data to encrypt"); + } + + let secretKeyObject + if(!isAdmins){ + if(groupSecretkeys[groupId] && groupSecretkeys[groupId].secretKeyObject && groupSecretkeys[groupId]?.timestamp && (Date.now() - groupSecretkeys[groupId]?.timestamp) < 1200000){ + secretKeyObject = groupSecretkeys[groupId].secretKeyObject + } + if(!secretKeyObject){ + const { names } = + await getGroupAdmins(groupId) + + const publish = + await getPublishesFromAdmins(names, groupId); + if(publish === false) throw new Error('No group key found.') + const url = await createEndpoint(`/arbitrary/DOCUMENT_PRIVATE/${publish.name}/${ + publish.identifier + }?encoding=base64`); + + const res = await fetch( +url + ); + const resData = await res.text(); + const decryptedKey: any = await decryptGroupEncryption({data: resData}); + + const dataint8Array = base64ToUint8Array(decryptedKey.data); + const decryptedKeyToObject = uint8ArrayToObject(dataint8Array); + if (!validateSecretKey(decryptedKeyToObject)) + throw new Error("SecretKey is not valid"); + secretKeyObject = decryptedKeyToObject + groupSecretkeys[groupId] = { + secretKeyObject, + timestamp: Date.now() + } + } +} else { + if(groupSecretkeys[`admins-${groupId}`] && groupSecretkeys[`admins-${groupId}`].secretKeyObject && groupSecretkeys[`admins-${groupId}`]?.timestamp && (Date.now() - groupSecretkeys[`admins-${groupId}`]?.timestamp) < 1200000){ + secretKeyObject = groupSecretkeys[`admins-${groupId}`].secretKeyObject + } + if(!secretKeyObject){ + const { names } = + await getGroupAdmins(groupId) + + const publish = + await getPublishesFromAdminsAdminSpace(names, groupId); + if(publish === false) throw new Error('No group key found.') + const url = await createEndpoint(`/arbitrary/DOCUMENT_PRIVATE/${publish.name}/${ + publish.identifier + }?encoding=base64`); + + const res = await fetch( +url + ); + const resData = await res.text(); + const decryptedKey: any = await decryptGroupEncryption({data: resData}); + + const dataint8Array = base64ToUint8Array(decryptedKey.data); + const decryptedKeyToObject = uint8ArrayToObject(dataint8Array); + if (!validateSecretKey(decryptedKeyToObject)) + throw new Error("SecretKey is not valid"); + secretKeyObject = decryptedKeyToObject + groupSecretkeys[`admins-${groupId}`] = { + secretKeyObject, + timestamp: Date.now() + } + } + + +} +console.log('secretKeyObject', secretKeyObject) + + const resGroupDecryptResource = decryptSingle({ + data64, secretKeyObject: secretKeyObject, skipDecodeBase64: true + }) + if (resGroupDecryptResource) { + return resGroupDecryptResource; + } else { + throw new Error("Unable to decrypt"); + } +}; + +export const encryptDataWithSharingKey = async (data, sender) => { + let data64 = data.data64; + let publicKeys = data.publicKeys || []; + if (data.fileId) { + data64 = await getFileFromContentScript(data.fileId, sender); + } + if (!data64) { + throw new Error("Please include data to encrypt"); + } + const symmetricKey = createSymmetricKeyAndNonce() + const dataObject = { + data: data64, + key:symmetricKey.messageKey + } + const dataObjectBase64 = await objectToBase64(dataObject) + + const resKeyPair = await getKeyPair(); + const parsedData = resKeyPair; + const privateKey = parsedData.privateKey; + const userPublicKey = parsedData.publicKey; + + const encryptDataResponse = encryptDataGroup({ + data64: dataObjectBase64, + publicKeys: publicKeys, + privateKey, + userPublicKey, + customSymmetricKey: symmetricKey.messageKey + }); + if (encryptDataResponse) { + return encryptDataResponse; + } else { + throw new Error("Unable to encrypt"); + } +}; + +export const decryptDataWithSharingKey = async (data, sender) => { + const { encryptedData, key } = data; + + if (!encryptedData) { + throw new Error("Please include data to decrypt"); + } + const decryptedData = await decryptGroupEncryptionWithSharingKey({data64EncryptedData: encryptedData, key}) + const base64ToObject = JSON.parse(atob(decryptedData)) + if(!base64ToObject.data) throw new Error('No data in the encrypted resource') + return base64ToObject.data +}; + +export const encryptQortalGroupData = async (data, sender) => { + let data64 = data.data64; + let groupId = data?.groupId + let isAdmins = data?.isAdmins + if(!groupId){ + throw new Error('Please provide a groupId') + } + if (data.fileId) { + data64 = await getFileFromContentScript(data.fileId, sender); + } + if (!data64) { + throw new Error("Please include data to encrypt"); + } + + + let secretKeyObject + if(!isAdmins){ + if(groupSecretkeys[groupId] && groupSecretkeys[groupId].secretKeyObject && groupSecretkeys[groupId]?.timestamp && (Date.now() - groupSecretkeys[groupId]?.timestamp) < 1200000){ + secretKeyObject = groupSecretkeys[groupId].secretKeyObject + } + + if(!secretKeyObject){ + const { names } = + await getGroupAdmins(groupId) + + const publish = + await getPublishesFromAdmins(names, groupId); + if(publish === false) throw new Error('No group key found.') + const url = await createEndpoint(`/arbitrary/DOCUMENT_PRIVATE/${publish.name}/${ + publish.identifier + }?encoding=base64`); + + const res = await fetch( +url + ); + const resData = await res.text(); + const decryptedKey: any = await decryptGroupEncryption({data: resData}); + + const dataint8Array = base64ToUint8Array(decryptedKey.data); + const decryptedKeyToObject = uint8ArrayToObject(dataint8Array); + + if (!validateSecretKey(decryptedKeyToObject)) + throw new Error("SecretKey is not valid"); + secretKeyObject = decryptedKeyToObject + groupSecretkeys[groupId] = { + secretKeyObject, + timestamp: Date.now() + } + } +} else { + + if(groupSecretkeys[`admins-${groupId}`] && groupSecretkeys[`admins-${groupId}`].secretKeyObject && groupSecretkeys[`admins-${groupId}`]?.timestamp && (Date.now() - groupSecretkeys[`admins-${groupId}`]?.timestamp) < 1200000){ + secretKeyObject = groupSecretkeys[`admins-${groupId}`].secretKeyObject + } + + if(!secretKeyObject){ + const { names } = + await getGroupAdmins(groupId) + + const publish = + await getPublishesFromAdminsAdminSpace(names, groupId); + if(publish === false) throw new Error('No group key found.') + const url = await createEndpoint(`/arbitrary/DOCUMENT_PRIVATE/${publish.name}/${ + publish.identifier + }?encoding=base64`); + + const res = await fetch( +url + ); + const resData = await res.text(); + const decryptedKey: any = await decryptGroupEncryption({data: resData}); + + const dataint8Array = base64ToUint8Array(decryptedKey.data); + const decryptedKeyToObject = uint8ArrayToObject(dataint8Array); + + if (!validateSecretKey(decryptedKeyToObject)) + throw new Error("SecretKey is not valid"); + secretKeyObject = decryptedKeyToObject + groupSecretkeys[`admins-${groupId}`] = { + secretKeyObject, + timestamp: Date.now() + } + } + + + +} + + const resGroupEncryptedResource = encryptSingle({ + data64, secretKeyObject: secretKeyObject, + }) + + if (resGroupEncryptedResource) { + return resGroupEncryptedResource; + } else { + throw new Error("Unable to encrypt"); + } }; \ No newline at end of file diff --git a/src/useQortalGetSaveSettings.tsx b/src/useQortalGetSaveSettings.tsx index 0223f12..69de292 100644 --- a/src/useQortalGetSaveSettings.tsx +++ b/src/useQortalGetSaveSettings.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect } from 'react' import { useRecoilState, useSetRecoilState } from 'recoil'; -import { canSaveSettingToQdnAtom, oldPinnedAppsAtom, settingsLocalLastUpdatedAtom, settingsQDNLastUpdatedAtom, sortablePinnedAppsAtom } from './atoms/global'; +import { canSaveSettingToQdnAtom, isUsingImportExportSettingsAtom, oldPinnedAppsAtom, settingsLocalLastUpdatedAtom, settingsQDNLastUpdatedAtom, sortablePinnedAppsAtom } from './atoms/global'; import { getArbitraryEndpointReact, getBaseApiReact } from './App'; import { decryptResource } from './components/Group/Group'; import { base64ToUint8Array, uint8ArrayToObject } from './backgroundFunctions/encryption'; @@ -53,13 +53,13 @@ const getPublishRecord = async (myName) => { } }; -export const useQortalGetSaveSettings = (myName) => { +export const useQortalGetSaveSettings = (myName, isAuthenticated) => { const setSortablePinnedApps = useSetRecoilState(sortablePinnedAppsAtom); const setCanSave = useSetRecoilState(canSaveSettingToQdnAtom); const setSettingsQDNLastUpdated = useSetRecoilState(settingsQDNLastUpdatedAtom); const [settingsLocalLastUpdated] = useRecoilState(settingsLocalLastUpdatedAtom); const [oldPinnedApps, setOldPinnedApps] = useRecoilState(oldPinnedAppsAtom) - + const [isUsingImportExportSettings] = useRecoilState(isUsingImportExportSettingsAtom); const getSavedSettings = useCallback(async (myName, settingsLocalLastUpdated)=> { try { const {hasPublishRecord, timestamp} = await getPublishRecord(myName) @@ -87,8 +87,9 @@ export const useQortalGetSaveSettings = (myName) => { } }, []) useEffect(()=> { - if(!myName || !settingsLocalLastUpdated) return + if(!myName || !settingsLocalLastUpdated || !isAuthenticated || isUsingImportExportSettings === null) return + if(isUsingImportExportSettings) return getSavedSettings(myName, settingsLocalLastUpdated) - }, [getSavedSettings, myName, settingsLocalLastUpdated]) + }, [getSavedSettings, myName, settingsLocalLastUpdated, isAuthenticated, isUsingImportExportSettings]) } diff --git a/src/useRetrieveDataLocalStorage.tsx b/src/useRetrieveDataLocalStorage.tsx index 6d7b05e..9ffe5af 100644 --- a/src/useRetrieveDataLocalStorage.tsx +++ b/src/useRetrieveDataLocalStorage.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect } from 'react' import { useSetRecoilState } from 'recoil'; -import { settingsLocalLastUpdatedAtom, sortablePinnedAppsAtom } from './atoms/global'; +import { isUsingImportExportSettingsAtom, oldPinnedAppsAtom, settingsLocalLastUpdatedAtom, settingsQDNLastUpdatedAtom, sortablePinnedAppsAtom } from './atoms/global'; function fetchFromLocalStorage(key) { try { @@ -19,17 +19,38 @@ function fetchFromLocalStorage(key) { export const useRetrieveDataLocalStorage = () => { const setSortablePinnedApps = useSetRecoilState(sortablePinnedAppsAtom); const setSettingsLocalLastUpdated = useSetRecoilState(settingsLocalLastUpdatedAtom); - + const setIsUsingImportExportSettings = useSetRecoilState(isUsingImportExportSettingsAtom) + const setSettingsQDNLastUpdated = useSetRecoilState(settingsQDNLastUpdatedAtom); + const setOldPinnedApps = useSetRecoilState(oldPinnedAppsAtom) + const getSortablePinnedApps = useCallback(()=> { const pinnedAppsLocal = fetchFromLocalStorage('ext_saved_settings') if(pinnedAppsLocal?.sortablePinnedApps){ setSortablePinnedApps(pinnedAppsLocal?.sortablePinnedApps) + setSettingsLocalLastUpdated(pinnedAppsLocal?.timestamp || -1) + } else { + setSettingsLocalLastUpdated(-1) } - setSettingsLocalLastUpdated(pinnedAppsLocal?.timestamp || -1) + + }, []) + const getSortablePinnedAppsImportExport = useCallback(()=> { + const pinnedAppsLocal = fetchFromLocalStorage('ext_saved_settings_import_export') + if(pinnedAppsLocal?.sortablePinnedApps){ + setOldPinnedApps(pinnedAppsLocal?.sortablePinnedApps) + + + setIsUsingImportExportSettings(true) + setSettingsQDNLastUpdated(pinnedAppsLocal?.timestamp || 0) + + } else { + setIsUsingImportExportSettings(false) + } + }, []) useEffect(()=> { getSortablePinnedApps() + getSortablePinnedAppsImportExport() }, [getSortablePinnedApps]) } diff --git a/src/utils/decode.ts b/src/utils/decode.ts new file mode 100644 index 0000000..3123810 --- /dev/null +++ b/src/utils/decode.ts @@ -0,0 +1,16 @@ +export function decodeIfEncoded(input) { + try { + // Check if input is URI-encoded by encoding and decoding + const encoded = encodeURIComponent(decodeURIComponent(input)); + if (encoded === input) { + // Input is URI-encoded, so decode it + return decodeURIComponent(input); + } + } catch (e) { + // decodeURIComponent throws an error if input is not encoded + console.error("Error decoding URI:", e); + } + + // Return input as-is if not URI-encoded + return input; + } \ No newline at end of file diff --git a/src/utils/fileReading/index.ts b/src/utils/fileReading/index.ts index c96dd74..a72f785 100644 --- a/src/utils/fileReading/index.ts +++ b/src/utils/fileReading/index.ts @@ -54,4 +54,14 @@ export const fileToBase64 = (file) => new Promise(async (resolve, reject) => { reject(error) semaphore.release() } -}) \ No newline at end of file +}) + +export const base64ToBlobUrl = (base64, mimeType = "image/png") => { + const binary = atob(base64); + const array = []; + for (let i = 0; i < binary.length; i++) { + array.push(binary.charCodeAt(i)); + } + const blob = new Blob([new Uint8Array(array)], { type: mimeType }); + return URL.createObjectURL(blob); + }; \ No newline at end of file diff --git a/src/utils/generateWallet/generateWallet.ts b/src/utils/generateWallet/generateWallet.ts index 7182d99..c4bd94f 100644 --- a/src/utils/generateWallet/generateWallet.ts +++ b/src/utils/generateWallet/generateWallet.ts @@ -2,6 +2,7 @@ import { crypto, walletVersion } from '../../constants/decryptWallet'; import { doInitWorkers, kdf } from '../../deps/kdf'; +import { mimeToExtensionMap } from '../memeTypes'; import PhraseWallet from './phrase-wallet'; import * as WORDLISTS from './wordlists'; import { saveAs } from 'file-saver'; @@ -74,6 +75,10 @@ export function generateRandomSentence(template = 'adverb verb noun adjective no return parse(template); } +const hasExtension = (filename) => { + return filename.includes(".") && filename.split(".").pop().length > 0; + }; + export const createAccount = async()=> { const generatedSeedPhrase = generateRandomSentence() const threads = doInitWorkers(crypto.kdfThreads) @@ -100,3 +105,16 @@ export const createAccount = async()=> { FileSaver.saveAs(blob, fileName); // Ensure FileSaver is properly imported or available in your environment. } } + +export const saveFileToDiskGeneric = async (blob, filename) => { + const timestamp = new Date() + .toISOString() + .replace(/:/g, "-"); // Safe timestamp for filenames + + const fileExtension = mimeToExtensionMap[blob.type] +let fileName = filename || "qortal_file_" + timestamp + "." + fileExtension; +fileName = hasExtension(fileName) ? fileName : fileName + "." + fileExtension; + +await saveAs(blob, fileName); + +} \ No newline at end of file diff --git a/src/utils/memeTypes.ts b/src/utils/memeTypes.ts index 2bc5873..2bd8ac0 100644 --- a/src/utils/memeTypes.ts +++ b/src/utils/memeTypes.ts @@ -12,10 +12,13 @@ export const mimeToExtensionMap = { "application/vnd.oasis.opendocument.presentation": ".odp", "text/plain": ".txt", "text/csv": ".csv", - "text/html": ".html", "application/xhtml+xml": ".xhtml", "application/xml": ".xml", - "application/json": ".json", + "application/rtf": ".rtf", + "application/vnd.apple.pages": ".pages", + "application/vnd.google-apps.document": ".gdoc", + "application/vnd.google-apps.spreadsheet": ".gsheet", + "application/vnd.google-apps.presentation": ".gslides", // Images "image/jpeg": ".jpg", @@ -25,6 +28,11 @@ export const mimeToExtensionMap = { "image/svg+xml": ".svg", "image/tiff": ".tif", "image/bmp": ".bmp", + "image/x-icon": ".ico", + "image/heic": ".heic", + "image/heif": ".heif", + "image/apng": ".apng", + "image/avif": ".avif", // Audio "audio/mpeg": ".mp3", @@ -32,6 +40,11 @@ export const mimeToExtensionMap = { "audio/wav": ".wav", "audio/webm": ".weba", "audio/aac": ".aac", + "audio/flac": ".flac", + "audio/x-m4a": ".m4a", + "audio/x-ms-wma": ".wma", + "audio/midi": ".midi", + "audio/x-midi": ".mid", // Video "video/mp4": ".mp4", @@ -45,6 +58,7 @@ export const mimeToExtensionMap = { "video/3gpp2": ".3g2", "video/x-matroska": ".mkv", "video/x-flv": ".flv", + "video/x-ms-asf": ".asf", // Archives "application/zip": ".zip", @@ -53,4 +67,57 @@ export const mimeToExtensionMap = { "application/x-7z-compressed": ".7z", "application/x-gzip": ".gz", "application/x-bzip2": ".bz2", -} \ No newline at end of file + "application/x-apple-diskimage": ".dmg", + "application/vnd.android.package-archive": ".apk", + "application/x-iso9660-image": ".iso", + + // Code Files + "text/javascript": ".js", + "text/css": ".css", + "text/html": ".html", + "application/json": ".json", + "text/xml": ".xml", + "application/x-sh": ".sh", + "application/x-csh": ".csh", + "text/x-python": ".py", + "text/x-java-source": ".java", + "application/java-archive": ".jar", + "application/vnd.microsoft.portable-executable": ".exe", + "application/x-msdownload": ".msi", + "text/x-c": ".c", + "text/x-c++": ".cpp", + "text/x-go": ".go", + "application/x-perl": ".pl", + "text/x-php": ".php", + "text/x-ruby": ".rb", + "text/x-sql": ".sql", + "application/x-httpd-php": ".php", + "application/x-python-code": ".pyc", + + // ROM Files + "application/x-nintendo-nes-rom": ".nes", + "application/x-snes-rom": ".smc", + "application/x-gameboy-rom": ".gb", + "application/x-gameboy-advance-rom": ".gba", + "application/x-n64-rom": ".n64", + "application/x-sega-genesis-rom": ".gen", + "application/x-sega-master-system-rom": ".sms", + "application/x-psx-rom": ".iso", // PlayStation ROMs + "application/x-bios-rom": ".rom", + "application/x-flash-rom": ".bin", + "application/x-eeprom": ".eep", + "application/x-c64-rom": ".prg", + + // Miscellaneous + "application/octet-stream": ".bin", // General binary files + "application/x-shockwave-flash": ".swf", + "application/x-silverlight-app": ".xap", + "application/x-ms-shortcut": ".lnk", + "application/vnd.ms-fontobject": ".eot", + "font/woff": ".woff", + "font/woff2": ".woff2", + "font/ttf": ".ttf", + "font/otf": ".otf", + "application/vnd.visio": ".vsd", + "application/vnd.ms-project": ".mpp", +};