batch of updates 1

This commit is contained in:
PhilReact 2024-12-28 11:18:08 +02:00
parent f464a5b049
commit d0719a30af
50 changed files with 7344 additions and 984 deletions

291
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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<MyContextInterface>(defaultValues);
export const GlobalContext = createContext<MyContextInterface>(defaultValuesGlobal);
export let globalApiKey: string | null = null;
@ -308,6 +316,7 @@ function App() {
const isFocusedRef = useRef<boolean>(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
</TextP>
</AuthenticatedContainerInnerLeft>
<AuthenticatedContainerInnerRight>
<Spacer height="20px" />
<img
onClick={() => {
setExtstate("download-wallet");
setIsOpenDrawerProfile(false);
<AuthenticatedContainerInnerRight sx={{
height: "100%",
justifyContent: "space-between",
}}>
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: 'center'
}}
src={Download}
style={{
cursor: "pointer",
}}
/>
>
<Spacer height="20px" />
{!isMobile && (
<>
<Spacer height="20px" />
@ -1539,6 +1552,29 @@ function App() {
}}
/>
)}
<Spacer height="20px" />
<CoreSyncStatus />
</Box>
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: 'center'
}}
>
<img
onClick={() => {
setExtstate("download-wallet");
setIsOpenDrawerProfile(false);
}}
src={Download}
style={{
cursor: "pointer",
}}
/>
<Spacer height="40px" />
</Box>
</AuthenticatedContainerInnerRight>
</AuthenticatedContainer>
);
@ -1554,7 +1590,14 @@ function App() {
backgroundRepeat: desktopViewMode === 'apps' && 'no-repeat',
}}
>
<GlobalContext.Provider value={{
showTutorial,
openTutorialModal,
setOpenTutorialModal,
downloadResource
}}>
<Tutorials />
{extState === "not-authenticated" && (
<NotAuthenticated getRootProps={getRootProps} getInputProps={getInputProps} setExtstate={setExtstate} apiKey={apiKey} globalApiKey={globalApiKey} setApiKey={setApiKey} handleSetGlobalApikey={handleSetGlobalApikey}/>
)}
@ -1574,6 +1617,7 @@ function App() {
show,
message,
rootHeight,
downloadResource
}}
>
<Box
@ -2920,6 +2964,7 @@ function App() {
>
{renderProfile()}
</DrawerComponent>
</GlobalContext.Provider>
</AppContainer>
);
}

View File

@ -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 (
<MessageQueueContext.Provider value={{ addToQueue, queueChats, clearStatesMessageQueueProvider, processWithNewMessages }}>
{children}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -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,
});

View File

@ -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}`;

View File

@ -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

View File

@ -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 };
};

View File

@ -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',

View File

@ -366,7 +366,7 @@ export const AppsDesktop = ({ mode, setMode, show , myName, goToHome, setDesktop
/>
</ButtonBase>
<Save isDesktop />
<Save isDesktop myName={myName} />
{mode !== 'home' && (
<AppsNavBarDesktop />

View File

@ -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);

View File

@ -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,
});
}
}
};

View File

@ -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() === '<p></p>') 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() === '<p></p>') 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 (
<div style={{
@ -485,7 +562,7 @@ const clearEditorContent = () => {
</>
)}
<ChatList onReply={onReply} chatId={selectedDirect?.address} initialMessages={messages} myAddress={myAddress} tempMessages={tempMessages}/>
<ChatList chatReferences={chatReferences} onEdit={onEdit} onReply={onReply} chatId={selectedDirect?.address} initialMessages={messages} myAddress={myAddress} tempMessages={tempMessages} tempChatReferences={tempChatReferences}/>
<div style={{
@ -525,6 +602,30 @@ const clearEditorContent = () => {
<ButtonBase
onClick={() => {
setReplyMessage(null)
setOnEditMessage(null)
}}
>
<ExitIcon />
</ButtonBase>
</Box>
)}
{onEditMessage && (
<Box sx={{
display: 'flex',
gap: '5px',
alignItems: 'flex-start',
width: '100%'
}}>
<ReplyPreview isEdit message={onEditMessage} />
<ButtonBase
onClick={() => {
setReplyMessage(null)
setOnEditMessage(null)
clearEditorContent()
}}
>
<ExitIcon />
@ -533,6 +634,20 @@ const clearEditorContent = () => {
)}
<Tiptap isFocusedParent={isFocusedParent} setEditorRef={setEditorRef} onEnter={sendMessage} isChat disableEnter={isMobile ? true : false} setIsFocusedParent={setIsFocusedParent}/>
{messageSize > 750 && (
<Box sx={{
display: 'flex',
width: '100%',
justifyContent: 'flex-start',
position: 'relative',
}}>
<Typography sx={{
fontSize: '12px',
color: messageSize > 4000 ? 'var(--danger)' : 'unset'
}}>{`Your message size is of ${messageSize} bytes out of a maximum of 4000`}</Typography>
</Box>
)}
</div>
<Box sx={{
display: 'flex',

View File

@ -15,16 +15,19 @@ import { CustomizedSnackbars } from '../Snackbar/Snackbar'
import { PUBLIC_NOTIFICATION_CODE_FIRST_SECRET_KEY } from '../../constants/codes'
import { useMessageQueue } from '../../MessageQueueContext'
import { executeEvent } from '../../utils/events'
import { Box, ButtonBase } from '@mui/material'
import { Box, ButtonBase, Divider, Typography } from '@mui/material'
import ShortUniqueId from "short-unique-id";
import { ReplyPreview } from './MessageItem'
import { ExitIcon } from '../../assets/Icons/ExitIcon'
import { RESOURCE_TYPE_NUMBER_GROUP_CHAT_REACTIONS } from '../../constants/resourceTypes'
import { isExtMsg } from '../../background'
import { throttle } from 'lodash'
import AppViewerContainer from '../Apps/AppViewerContainer'
import CloseIcon from "@mui/icons-material/Close";
const uid = new ShortUniqueId({ length: 5 });
export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, myAddress, handleNewEncryptionNotification, hide, handleSecretKeyCreationInProgress, triedToFetchSecretKey, myName, balance}) => {
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: "<p>First group key created.</p>"
} : {}
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: "<p>First group key created.</p>"
} : {}
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() === '<p></p>') 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() === '<p></p>') 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 (
<div style={{
@ -628,17 +770,16 @@ const clearEditorContent = () => {
left: hide && '-100000px',
}}>
<ChatList onReply={onReply} chatId={selectedGroup} initialMessages={messages} myAddress={myAddress} tempMessages={tempMessages} handleReaction={handleReaction} chatReferences={chatReferences} tempChatReferences={tempChatReferences}/>
<ChatList isPrivate={isPrivate} hasSecretKey={!!secretKey} openQManager={openQManager} enableMentions onReply={onReply} onEdit={onEdit} chatId={selectedGroup} initialMessages={messages} myAddress={myAddress} tempMessages={tempMessages} handleReaction={handleReaction} chatReferences={chatReferences} tempChatReferences={tempChatReferences} members={members} myName={myName} selectedGroup={selectedGroup} />
{(!!secretKey || isPrivate === false) && (
<div style={{
// position: 'fixed',
// bottom: '0px',
backgroundColor: "#232428",
minHeight: isMobile ? '0px' : '150px',
maxHeight: isMobile ? 'auto' : '400px',
display: 'flex',
flexDirection: 'column',
flexDirection: 'row',
overflow: 'hidden',
width: '100%',
boxSizing: 'border-box',
@ -649,12 +790,15 @@ const clearEditorContent = () => {
zIndex: isFocusedParent ? 5 : 'unset',
flexShrink: 0
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
flexGrow: isMobile && 1,
overflow: !isMobile && "auto",
flexShrink: 0
flexShrink: 0,
width: 'calc(100% - 100px)',
justifyContent: 'flex-end'
}}>
{replyMessage && (
<Box sx={{
@ -668,6 +812,31 @@ const clearEditorContent = () => {
<ButtonBase
onClick={() => {
setReplyMessage(null)
setOnEditMessage(null)
}}
>
<ExitIcon />
</ButtonBase>
</Box>
)}
{onEditMessage && (
<Box sx={{
display: 'flex',
gap: '5px',
alignItems: 'flex-start',
width: '100%'
}}>
<ReplyPreview isEdit message={onEditMessage} />
<ButtonBase
onClick={() => {
setReplyMessage(null)
setOnEditMessage(null)
clearEditorContent()
}}
>
<ExitIcon />
@ -676,40 +845,35 @@ const clearEditorContent = () => {
)}
<Tiptap setEditorRef={setEditorRef} onEnter={sendMessage} isChat disableEnter={isMobile ? true : false} isFocusedParent={isFocusedParent} setIsFocusedParent={setIsFocusedParent} />
<Tiptap enableMentions setEditorRef={setEditorRef} onEnter={sendMessage} isChat disableEnter={isMobile ? true : false} isFocusedParent={isFocusedParent} setIsFocusedParent={setIsFocusedParent} membersWithNames={members} />
{messageSize > 750 && (
<Box sx={{
display: 'flex',
width: '100%',
justifyContent: 'flex-start',
position: 'relative',
}}>
<Typography sx={{
fontSize: '12px',
color: messageSize > 4000 ? 'var(--danger)' : 'unset'
}}>{`Your message size is of ${messageSize} bytes out of a maximum of 4000`}</Typography>
</Box>
)}
</div>
<Box sx={{
display: 'flex',
width: '100&',
width: '100px',
gap: '10px',
justifyContent: 'center',
flexShrink: 0,
position: 'relative',
}}>
{isFocusedParent && (
<CustomButton
onClick={()=> {
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`}
</CustomButton>
)}
<CustomButton
onClick={()=> {
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 = () => {
</CustomButton>
</Box>
{/* <button onClick={sendMessage}>send</button> */}
</div>
)}
{isOpenQManager !== null && (
<Box sx={{
position: 'fixed',
height: '600px',
maxHeight: '100vh',
width: '400px',
maxWidth: '100vw',
backgroundColor: '#27282c',
zIndex: 100,
bottom: 0,
right: 0,
overflow: 'hidden',
borderTopLeftRadius: '10px',
borderTopRightRadius: '10px',
display: isOpenQManager === true ? 'block' : 'none',
boxShadow: 4,
}}>
<Box sx={{
height: '100%',
width: '100%',
}}>
<Box sx={{
height: '40px',
display: 'flex',
alignItems: 'center',
padding: '5px',
justifyContent: 'space-between'
}}>
<Typography>Q-Manager</Typography>
<ButtonBase onClick={()=> {
setIsOpenQManager(false)
}}><CloseIcon sx={{
color: 'white'
}} /></ButtonBase>
</Box>
<Divider />
<AppViewerContainer customHeight="560px" app={{
tabId: '5558588',
name: 'Q-Manager',
service: 'APP',
path: `?groupId=${selectedGroup}`
}} isSelected />
</Box>
</Box>
)}
{/* <ChatContainerComp messages={formatMessages} /> */}
<LoadingSnackbar open={isLoading} info={{
message: "Loading chat... please wait."

View File

@ -9,6 +9,9 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { MessageItem } from "./MessageItem";
import { subscribeToEvent, unsubscribeFromEvent } from "../../utils/events";
import { useInView } from "react-intersection-observer";
import { Box, Typography } from "@mui/material";
import { ChatOptions } from "./ChatOptions";
import ErrorBoundary from "../../common/ErrorBoundary";
export const ChatList = ({
initialMessages,
@ -16,24 +19,73 @@ export const ChatList = ({
tempMessages,
chatId,
onReply,
onEdit,
handleReaction,
chatReferences,
tempChatReferences,
members,
myName,
selectedGroup,
enableMentions,
openQManager,
hasSecretKey,
isPrivate
}) => {
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 (
<div
style={{
height: "100%",
position: "relative",
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
}}
>
<div
ref={parentRef}
className="List"
style={{
flexGrow: 1,
overflow: "auto",
height: "100%",
position: "relative",
display: "flex",
height: "0px",
flexDirection: "column",
width: "100%",
}}
>
<div
ref={parentRef}
className="List"
style={{
height: rowVirtualizer.getTotalSize(),
width: "100%",
flexGrow: 1,
overflow: "auto",
position: "relative",
display: "flex",
height: "0px",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
height: rowVirtualizer.getTotalSize(),
width: "100%",
// transform: `translateY(${rowVirtualizer.getVirtualItems()[0]?.start ?? 0}px)`,
}}
>
{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;
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
}}
>
{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 (
<div
key={virtualRow.index}
style={{
position: "absolute",
top: 0,
left: "50%",
transform: `translateY(${virtualRow.start}px) translateX(-50%)`,
width: "100%",
padding: "10px 0",
display: "flex",
alignItems: "center",
flexDirection: "column",
gap: "5px",
}}
>
<Typography>Error loading message.</Typography>
</div>
);
}
if (message?.repliedTo && replyIndex !== -1) {
reply = messages[replyIndex];
}
if (message?.message && message?.groupDirectId) {
replyIndex = messages.findIndex(
(msg) => msg?.signature === message?.message?.repliedTo
return (
<div
data-index={virtualRow.index} //needed for dynamic row height measurement
ref={rowVirtualizer.measureElement} //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",
alignItems: "center",
overscrollBehavior: "none",
flexDirection: "column",
gap: "5px",
}}
>
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<MessageItem
isLast={index === messages.length - 1}
lastSignature={lastSignature}
message={message}
onSeen={handleMessageSeen}
isTemp={!!message?.isTemp}
myAddress={myAddress}
onReply={onReply}
onEdit={onEdit}
reply={reply}
replyIndex={replyIndex}
scrollToItem={goToMessage}
handleReaction={handleReaction}
reactions={reactions}
isUpdating={isUpdating}
isPrivate={isPrivate}
/>
</ErrorBoundary>
</div>
);
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 (
<div
data-index={virtualRow.index} //needed for dynamic row height measurement
ref={(node) => 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",
}}
>
<MessageItem
isLast={index === messages.length - 1}
lastSignature={lastSignature}
message={message}
onSeen={handleMessageSeen}
isTemp={!!message?.isTemp}
myAddress={myAddress}
onReply={onReply}
reply={reply}
replyIndex={replyIndex}
scrollToItem={(idx) => rowVirtualizer.scrollToIndex(idx)}
handleReaction={handleReaction}
reactions={reactions}
isUpdating={isUpdating}
/>
</div>
);
})}
})}
</div>
</div>
</div>
{showScrollButton && (
<button
onClick={() => scrollToBottom()}
style={{
bottom: 20,
position: "absolute",
right: 20,
backgroundColor: "var(--unread)",
color: "black",
padding: "10px 20px",
borderRadius: "20px",
cursor: "pointer",
zIndex: 10,
border: "none",
outline: "none",
}}
>
Scroll to Unread Messages
</button>
)}
{showScrollDownButton && !showScrollButton && (
<button
onClick={() => scrollToBottom()}
style={{
bottom: 20,
position: "absolute",
right: 20,
backgroundColor: "var(--Mail-Background)",
color: "white",
padding: "10px 20px",
borderRadius: "20px",
cursor: "pointer",
zIndex: 10,
border: "none",
outline: "none",
fontSize: "16px",
}}
>
Scroll to bottom
</button>
)}
</div>
{showScrollButton && (
<button
onClick={() => scrollToBottom()}
style={{
position: "absolute",
bottom: 20,
right: 20,
backgroundColor: "#ff5a5f",
color: "white",
padding: "10px 20px",
borderRadius: "20px",
cursor: "pointer",
zIndex: 10,
}}
>
Scroll to Unread Messages
</button>
{enableMentions && (hasSecretKey || isPrivate === false) && (
<ChatOptions
openQManager={openQManager}
messages={messages}
goToMessage={goToMessage}
members={members}
myName={myName}
selectedGroup={selectedGroup}
isPrivate={isPrivate}
/>
)}
</div>
</Box>
);
};

View File

@ -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 (
<Box
sx={{
width: "300px",
height: "100%",
display: "flex",
flexDirection: "column",
// alignItems: 'center',
backgroundColor: "#1F2023",
borderBottomLeftRadius: "20px",
borderTopLeftRadius: "20px",
overflow: "auto",
flexShrink: 0,
flexGrow: 0,
}}
>
<Box
sx={{
padding: "10px",
display: "flex",
justifyContent: "flex-end",
}}
>
<CloseIcon
onClick={() => {
setMode("default");
}}
sx={{
cursor: "pointer",
color: "white",
}}
/>
</Box>
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{mentionList?.length === 0 && (
<Typography
sx={{
fontSize: "11px",
fontWeight: 400,
color: "rgba(255, 255, 255, 0.2)",
}}
>
No results
</Typography>
)}
<Box
sx={{
display: "flex",
width: "100%",
height: "100%",
}}
>
<div
style={{
height: "100%",
position: "relative",
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<div
ref={parentRefMentions}
className="List"
style={{
flexGrow: 1,
overflow: "auto",
position: "relative",
display: "flex",
height: "0px",
}}
>
<div
style={{
height: rowVirtualizerMentions.getTotalSize(),
width: "100%",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
}}
>
{rowVirtualizerMentions
.getVirtualItems()
.map((virtualRow) => {
const index = virtualRow.index;
let message = mentionList[index];
return (
<div
data-index={virtualRow.index} //needed for dynamic row height measurement
ref={rowVirtualizerMentions.measureElement} //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",
alignItems: "center",
overscrollBehavior: "none",
flexDirection: "column",
gap: "5px",
}}
>
<ShowMessage
messages={messages}
goToMessage={goToMessage}
message={message}
/>
</div>
);
})}
</div>
</div>
</div>
</div>
</Box>
</Box>
</Box>
);
}
if (mode === "search") {
return (
<Box
sx={{
width: "300px",
height: "100%",
display: "flex",
flexDirection: "column",
// alignItems: 'center',
backgroundColor: "#1F2023",
borderBottomLeftRadius: "20px",
borderTopLeftRadius: "20px",
overflow: "auto",
flexShrink: 0,
flexGrow: 0,
}}
>
<Box
sx={{
padding: "10px",
display: "flex",
justifyContent: "flex-end",
}}
>
<CloseIcon
onClick={() => {
setMode("default");
}}
sx={{
cursor: "pointer",
color: "white",
}}
/>
</Box>
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<AppsSearchContainer>
<AppsSearchLeft>
<img src={IconSearch} />
<InputBase
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
sx={{ ml: 1, flex: 1 }}
placeholder="Search chat text"
inputProps={{
"aria-label": "Search for apps",
fontSize: "16px",
fontWeight: 400,
}}
/>
</AppsSearchLeft>
<AppsSearchRight>
{searchValue && (
<ButtonBase
onClick={() => {
setSearchValue("");
}}
>
<img src={IconClearInput} />
</ButtonBase>
)}
</AppsSearchRight>
</AppsSearchContainer>
<Box
sx={{
padding: "10px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Select
size="small"
labelId="demo-simple-select-label"
id="demo-simple-select"
value={selectedMember}
label="By member"
onChange={(e) => setSelectedMember(e.target.value)}
>
<MenuItem value={0}>
<em>By member</em>
</MenuItem>
{members?.map((member) => {
return (
<MenuItem key={member} value={member}>
{member}
</MenuItem>
);
})}
</Select>
{!!selectedMember && (
<CloseIcon
onClick={() => {
setSelectedMember(0);
}}
sx={{
cursor: "pointer",
color: "white",
}}
/>
)}
</Box>
{debouncedValue && searchedList?.length === 0 && (
<Typography
sx={{
fontSize: "11px",
fontWeight: 400,
color: "rgba(255, 255, 255, 0.2)",
}}
>
No results
</Typography>
)}
<Box
sx={{
display: "flex",
width: "100%",
height: "100%",
}}
>
<div
style={{
height: "100%",
position: "relative",
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<div
ref={parentRef}
className="List"
style={{
flexGrow: 1,
overflow: "auto",
position: "relative",
display: "flex",
height: "0px",
}}
>
<div
style={{
height: rowVirtualizer.getTotalSize(),
width: "100%",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const index = virtualRow.index;
let message = searchedList[index];
return (
<div
data-index={virtualRow.index} //needed for dynamic row height measurement
ref={rowVirtualizer.measureElement} //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",
alignItems: "center",
overscrollBehavior: "none",
flexDirection: "column",
gap: "5px",
}}
>
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<ShowMessage
message={message}
goToMessage={goToMessage}
messages={messages}
/>
</ErrorBoundary>
</div>
);
})}
</div>
</div>
</div>
</div>
</Box>
</Box>
</Box>
);
}
return (
<Box
sx={{
width: "50px",
height: "100%",
gap: "20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Box
sx={{
width: "100%",
padding: "10px",
gap: "20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
backgroundColor: "#1F2023",
borderBottomLeftRadius: "20px",
borderTopLeftRadius: "20px",
minHeight: "200px",
}}
>
<ButtonBase
onClick={() => {
setMode("search");
}}
>
<SearchIcon />
</ButtonBase>
<ButtonBase
onClick={() => {
setMode("default");
setSearchValue("");
setSelectedMember(0);
openQManager();
}}
>
<InsertLinkIcon
sx={{
color: "white",
}}
/>
</ButtonBase>
<ContextMenuMentions
getTimestampMention={getTimestampMention}
groupId={selectedGroup}
>
<ButtonBase
onClick={() => {
setMode("mentions");
setSearchValue("");
setSelectedMember(0);
}}
>
<AlternateEmailIcon
sx={{
color:
mentionList?.length > 0 &&
(!lastMentionTimestamp ||
lastMentionTimestamp < mentionList[0]?.timestamp)
? "var(--unread)"
: "white",
}}
/>
</ButtonBase>
</ContextMenuMentions>
</Box>
</Box>
);
};
const ShowMessage = ({ message, goToMessage, messages }) => {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
padding: "0px 20px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "15px",
}}
>
<Avatar
sx={{
backgroundColor: "#27282c",
color: "white",
height: "25px",
width: "25px",
}}
alt={message?.senderName}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${
message?.senderName
}/qortal_avatar?async=true`}
>
{message?.senderName?.charAt(0)}
</Avatar>
<Typography
sx={{
fontWight: 600,
fontFamily: "Inter",
color: "cadetBlue",
}}
>
{message?.senderName}
</Typography>
</Box>
</Box>
<Spacer height="5px" />
<Typography
sx={{
fontSize: "12px",
}}
>
{formatTimestamp(message.timestamp)}
</Typography>
<Box
style={{
cursor: "pointer",
}}
onClick={() => {
const findMsgIndex = messages.findIndex(
(item) => item?.signature === message?.signature
);
if (findMsgIndex !== -1) {
goToMessage(findMsgIndex);
}
}}
>
{message?.messageText && (
<MessageDisplay htmlContent={message?.messageText} />
)}
{message?.decryptedData?.message && (
<MessageDisplay
htmlContent={message?.decryptedData?.message || "<p></p>"}
/>
)}
</Box>
</Box>
);
};

View File

@ -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 (
<div className="dropdown-menu">
{props.items.length
? props.items.map((item, index) => (
<button
className={index === selectedIndex ? 'is-selected' : ''}
key={item.id || index}
onClick={() => selectItem(index)}
>
{item.label}
</button>
))
: <div className="item">No result</div>
}
</div>
)
})

View File

@ -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 `<a href="${DOMPurify.sanitize(
href
)}" class="auto-link">${DOMPurify.sanitize(url)}</a>`;
const href = url.startsWith('http') ? url : `https://${url}`;
return `<a href="${DOMPurify.sanitize(href)}" class="auto-link">${DOMPurify.sanitize(url)}</a>`;
});
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(/<span[^>]*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 && (
<Embed embedLink={embedData} />
)}
<div
className={`tiptap ${isReply ? "isReply" : ""}`}
className={`tiptap ${isReply ? 'isReply' : ''}`}
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
onClick={handleClick}
/>
</>
);
};

View File

@ -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 && (
<div className="unread-divider" id="unread-divider-id">
Unread messages below
</div>
)}
<div
ref={lastSignature === message?.signature ? ref : null}
style={{
@ -76,9 +91,9 @@ export const MessageItem = ({
color: "white",
}}
alt={message?.senderName}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${
src={message?.senderName ? `${getBaseApiReact()}/arbitrary/THUMBNAIL/${
message?.senderName
}/qortal_avatar?async=true`}
}/qortal_avatar?async=true` : ''}
>
{message?.senderName?.charAt(0)}
</Avatar>
@ -122,6 +137,15 @@ export const MessageItem = ({
gap: '10px',
alignItems: 'center'
}}>
{message?.sender === myAddress && (!message?.isNotEncrypted || isPrivate === false) && (
<ButtonBase
onClick={() => {
onEdit(message);
}}
>
<EditIcon />
</ButtonBase>
)}
{!isShowingAsReply && (
<ButtonBase
onClick={() => {
@ -182,13 +206,16 @@ export const MessageItem = ({
StarterKit,
Underline,
Highlight,
Mention,
TextStyle
])}
setMobileViewModeKeepOpen={setMobileViewModeKeepOpen}
/>
)}
{reply?.decryptedData?.type === "notification" ? (
<MessageDisplay htmlContent={reply.decryptedData?.data?.message} />
) : (
<MessageDisplay isReply htmlContent={reply.text} />
<MessageDisplay setMobileViewModeKeepOpen={setMobileViewModeKeepOpen} isReply htmlContent={reply.text} />
)}
</Box>
</Box>
@ -200,13 +227,16 @@ export const MessageItem = ({
StarterKit,
Underline,
Highlight,
Mention,
TextStyle
])}
setMobileViewModeKeepOpen={setMobileViewModeKeepOpen}
/>
)}
{message?.decryptedData?.type === "notification" ? (
<MessageDisplay htmlContent={message.decryptedData?.data?.message} />
) : (
<MessageDisplay htmlContent={message.text} />
<MessageDisplay setMobileViewModeKeepOpen={setMobileViewModeKeepOpen} htmlContent={message.text} />
)}
<Box
sx={{
@ -225,18 +255,16 @@ export const MessageItem = ({
// const myReaction = reactions
if(numberOfReactions === 0) return null
return (
<ButtonBase sx={{
<ButtonBase key={reaction} sx={{
height: '30px',
minWidth: '45px',
background: 'var(--bg-2)',
borderRadius: '7px'
}} onClick={()=> {
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);
}}>
<div>{reaction}</div> {numberOfReactions > 1 && (
<Typography sx={{
marginLeft: '4px'
@ -246,12 +274,79 @@ export const MessageItem = ({
)
})}
</Box>
{selectedReaction && (
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => {
setAnchorEl(null);
setSelectedReaction(null);
}}
anchorOrigin={{
vertical: "top",
horizontal: "center",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "center",
}}
PaperProps={{
style: {
backgroundColor: "#232428",
color: "white",
},
}}
>
<Box sx={{ p: 2 }}>
<Typography variant="subtitle1" sx={{ marginBottom: 1 }}>
People who reacted with {selectedReaction}
</Typography>
<List sx={{
overflow: 'auto',
maxWidth: '80vw',
maxHeight: '300px'
}}>
{reactions[selectedReaction]?.map((reactionItem) => (
<ListItem key={reactionItem.sender}>
<ListItemText
primary={reactionItem.senderName || reactionItem.sender}
/>
</ListItem>
))}
</List>
<Button
variant="contained"
color="primary"
onClick={() => {
if (
reactions[selectedReaction]?.find(
(item) => item?.sender === myAddress
)
) {
handleReaction(selectedReaction, message, false); // Remove reaction
} else {
handleReaction(selectedReaction, message, true); // Add reaction
}
setAnchorEl(null);
setSelectedReaction(null);
}}
sx={{ marginTop: 2 }}
>
{reactions[selectedReaction]?.find(
(item) => item?.sender === myAddress
)
? "Remove Reaction"
: "Add Reaction"}
</Button>
</Box>
</Popover>
)}
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: '15px'
}}>
{message?.isNotEncrypted && (
{message?.isNotEncrypted && isPrivate && (
<KeyOffIcon sx={{
color: 'white',
marginLeft: '10px'
@ -279,6 +374,19 @@ export const MessageItem = ({
{message?.status === 'failed-permanent' ? 'Failed to send' : 'Sending...'}
</Typography>
) : (
<>
{message?.isEdit && (
<Typography
sx={{
fontSize: "14px",
color: "gray",
fontFamily: "Inter",
fontStyle: 'italic'
}}
>
Edited
</Typography>
)}
<Typography
sx={{
fontSize: "14px",
@ -288,6 +396,7 @@ export const MessageItem = ({
>
{formatTimestamp(message.timestamp)}
</Typography>
</>
)}
</Box>
</Box>
@ -305,11 +414,12 @@ export const MessageItem = ({
></Message> */}
{/* {!message.unread && <span style={{ color: 'green' }}> Seen</span>} */}
</div>
</>
);
};
export const ReplyPreview = ({message})=> {
export const ReplyPreview = ({message, isEdit})=> {
return (
<Box
@ -333,16 +443,26 @@ export const ReplyPreview = ({message})=> {
<Box sx={{
padding: '5px'
}}>
<Typography sx={{
fontSize: '12px',
fontWeight: 600
}}>Replied to {message?.senderName || message?.senderAddress}</Typography>
{isEdit ? (
<Typography sx={{
fontSize: '12px',
fontWeight: 600
}}>Editing Message</Typography>
) : (
<Typography sx={{
fontSize: '12px',
fontWeight: 600
}}>Replied to {message?.senderName || message?.senderAddress}</Typography>
)}
{message?.messageText && (
<MessageDisplay
htmlContent={generateHTML(message?.messageText, [
StarterKit,
Underline,
Highlight,
Mention,
TextStyle
])}
/>
)}

View File

@ -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 (
<div>
<EditorProvider
slotBefore={
(isFocusedParent || !isMobile || overrideMobile) && (
<MenuBar setEditorRef={setEditorRefFunc} isChat={isChat} />
)
}
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 ({
},
}}
/>
</div>
);
};

View File

@ -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;
}
}
}

View File

@ -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 (
<div
onContextMenu={handleContextMenu} // For desktop right-click
onTouchStart={handleTouchStart} // For mobile long-press start
onTouchEnd={handleTouchEnd} // For mobile long-press end
style={{ width: "100%", height: "100%" }}
>
{children}
<CustomStyledMenu
disableAutoFocusItem
open={!!menuPosition}
onClose={handleClose}
anchorReference="anchorPosition"
anchorPosition={
menuPosition
? { top: menuPosition.mouseY, left: menuPosition.mouseX }
: undefined
}
onClick={(e) => {
e.stopPropagation();
}}
>
<MenuItem
onClick={(e) => {
handleClose(e);
addTimestamp()
}}
>
<Typography variant="inherit" sx={{ fontSize: "14px" }}>
Unmark
</Typography>
</MenuItem>
</CustomStyledMenu>
</div>
);
};

View File

@ -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);
}

View File

@ -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 (
<div className="tooltip" style={{ display: 'inline' }}>
<span><img src={imagePath} style={{ height: 'auto', width: imageSize ? imageSize : '24px' }} alt="sync status" /></span>
<div className="bottom" style={{
right: position && 'unset',
left: position && '0px'
}}>
<h3>Core Information</h3>
<h4 className="lineHeight">Core Version: <span style={{ color: '#03a9f4' }}>{buildVersion}</span></h4>
<h4 className="lineHeight">{message}</h4>
<h4 className="lineHeight">Block Height: <span style={{ color: '#03a9f4' }}>{height || ''}</span></h4>
<h4 className="lineHeight">Connected Peers: <span style={{ color: '#03a9f4' }}>{numberOfConnections || ''}</span></h4>
<h4 className="lineHeight">Using gateway: <span style={{ color: '#03a9f4' }}>{isUsingGateway?.toString()}</span></h4>
<i></i>
</div>
</div>
);
};
return (
<div id="core-sync-status-id">
{renderSyncStatusIcon()}
</div>
);
};

View File

@ -78,7 +78,8 @@ export const DesktopFooter = ({
desktopViewMode,
hide,
setIsOpenSideViewDirects,
setIsOpenSideViewGroups
setIsOpenSideViewGroups,
myName
}) => {
@ -178,7 +179,7 @@ export const DesktopFooter = ({
</IconWrapper>
</ButtonBase>
<Save isDesktop />
<Save isDesktop myName={myName} />
</Box>
</Box>
);

View File

@ -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 (
<Card
sx={{
backgroundColor: "#1F2023",
height: "250px",
// height: isOpen ? "auto" : "150px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 16px 0px 16px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
<AttachmentIcon
sx={{
color: "white",
}}
/>
<Typography>ATTACHMENT embed</Typography>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
<ButtonBase>
<RefreshIcon
onClick={refresh}
sx={{
fontSize: "24px",
color: "white",
}}
/>
</ButtonBase>
{external && (
<ButtonBase>
<OpenInNewIcon
onClick={openExternal}
sx={{
fontSize: "24px",
color: "white",
}}
/>
</ButtonBase>
)}
</Box>
</Box>
<Box
sx={{
padding: "8px 16px 8px 16px",
}}
>
<Typography
sx={{
fontSize: "12px",
color: "white",
}}
>
Created by {decodeIfEncoded(owner)}
</Typography>
<Typography
sx={{
fontSize: "12px",
color: "cadetblue",
}}
>
{encryptionType === 'private' ? "ENCRYPTED" : encryptionType === 'group' ? 'GROUP ENCRYPTED' : "Not encrypted"}
</Typography>
</Box>
<Divider sx={{ borderColor: "rgb(255 255 255 / 10%)" }} />
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
alignItems: "center",
}}
>
{isLoadingParent && isOpen && (
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
}}
>
{" "}
<CustomLoader />{" "}
</Box>
)}
{errorMsg && (
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
}}
>
{" "}
<Typography
sx={{
fontSize: "14px",
color: "var(--danger)",
}}
>
{errorMsg}
</Typography>{" "}
</Box>
)}
</Box>
<Box>
<CardContent>
{resourceData?.fileName && (
<>
<Typography sx={{
fontSize: '14px'
}}>{resourceData?.fileName}</Typography>
<Spacer height="10px" />
</>
)}
<ButtonBase sx={{
width: '90%',
maxWidth: '400px'
}} onClick={()=> {
if(resourceDetails?.status?.status === 'READY'){
if(encryptionType){
saveToDiskEncrypted()
return
}
saveToDisk()
return
}
downloadResource(resourceData)
}}>
<FileAttachmentContainer >
<Typography>{resourceDetails?.status?.status === 'DOWNLOADED' ? 'BUILDING' : resourceDetails?.status?.status}</Typography>
{!resourceDetails && (
<>
<DownloadIcon />
<FileAttachmentFont>Download File</FileAttachmentFont>
</>
)}
{resourceDetails && resourceDetails?.status?.status !== 'READY' && resourceDetails?.status?.status !== 'FAILED_TO_DOWNLOAD' && (
<>
<CircularProgress sx={{
color: 'white'
}} size={20} />
<FileAttachmentFont>Downloading: {resourceDetails?.status?.percentLoaded || '0'}%</FileAttachmentFont>
</>
)}
{resourceDetails && resourceDetails?.status?.status === 'READY' && (
<>
<SaveIcon />
<FileAttachmentFont>Save to Disk</FileAttachmentFont>
</>
)}
</FileAttachmentContainer>
</ButtonBase>
</CardContent>
</Box>
</Card>
);
};

View File

@ -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",
}));

View File

@ -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 (
<div>
{parsedType === "POLL" && (
<PollCard
poll={poll}
refresh={handleLink}
setInfoSnack={setInfoSnack}
setOpenSnack={setOpenSnack}
external={external}
openExternal={openExternal}
isLoadingParent={isLoading}
errorMsg={errorMsg}
/>
)}
{parsedType === "IMAGE" && (
<ImageCard
image={imageUrl}
owner={parsedData?.name}
fetchImage={fetchImage}
refresh={fetchImage}
setInfoSnack={setInfoSnack}
setOpenSnack={setOpenSnack}
external={external}
openExternal={openExternal}
isLoadingParent={isLoading}
errorMsg={errorMsg}
encryptionType={encryptionType}
/>
)}
{parsedType === 'ATTACHMENT' && (
<AttachmentCard
resourceData={resourceData}
resourceDetails={resourceDetails}
owner={parsedData?.name}
refresh={fetchImage}
setInfoSnack={setInfoSnack}
setOpenSnack={setOpenSnack}
external={external}
openExternal={openExternal}
isLoadingParent={isLoading}
errorMsg={errorMsg}
encryptionType={encryptionType}
selectedGroupId={selectedGroupId}
/>
)}
<CustomizedSnackbars
duration={2000}
open={openSnack}
setOpen={setOpenSnack}
info={infoSnack}
setInfo={setInfoSnack}
/>
</div>
);
};

View File

@ -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 (
<Card
sx={{
backgroundColor: "#1F2023",
height: height,
transition: "height 0.6s ease-in-out",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 16px 0px 16px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
<ImageIcon
sx={{
color: "white",
}}
/>
<Typography>IMAGE embed</Typography>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
<ButtonBase>
<RefreshIcon
onClick={refresh}
sx={{
fontSize: "24px",
color: "white",
}}
/>
</ButtonBase>
{external && (
<ButtonBase>
<OpenInNewIcon
onClick={openExternal}
sx={{
fontSize: "24px",
color: "white",
}}
/>
</ButtonBase>
)}
</Box>
</Box>
<Box
sx={{
padding: "8px 16px 8px 16px",
}}
>
<Typography
sx={{
fontSize: "12px",
color: "white",
}}
>
Created by {decodeIfEncoded(owner)}
</Typography>
<Typography
sx={{
fontSize: "12px",
color: "cadetblue",
}}
>
{encryptionType === 'private' ? "ENCRYPTED" : encryptionType === 'group' ? 'GROUP ENCRYPTED' : "Not encrypted"}
</Typography>
</Box>
<Divider sx={{ borderColor: "rgb(255 255 255 / 10%)" }} />
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
alignItems: "center",
}}
>
{isLoadingParent && isOpen && (
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
}}
>
{" "}
<CustomLoader />{" "}
</Box>
)}
{errorMsg && (
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
}}
>
{" "}
<Typography
sx={{
fontSize: "14px",
color: "var(--danger)",
}}
>
{errorMsg}
</Typography>{" "}
</Box>
)}
</Box>
<Box>
<CardContent>
<ImageViewer src={image} />
</CardContent>
</Box>
</Card>
);
};
export function ImageViewer({ src, alt = "" }) {
const [isFullscreen, setIsFullscreen] = useState(false);
const handleOpenFullscreen = () => setIsFullscreen(true);
const handleCloseFullscreen = () => setIsFullscreen(false);
return (
<>
{/* Image in container */}
<Box
sx={{
maxWidth: "100%", // Prevent horizontal overflow
display: "flex",
justifyContent: "center",
cursor: "pointer",
}}
onClick={handleOpenFullscreen}
>
<img
src={src}
alt={alt}
style={{
maxWidth: "100%",
maxHeight: "450px", // Adjust max height for small containers
objectFit: "contain", // Preserve aspect ratio
}}
/>
</Box>
{/* Fullscreen Viewer */}
<Dialog
open={isFullscreen}
onClose={handleCloseFullscreen}
maxWidth="lg"
fullWidth
fullScreen
sx={{
"& .MuiDialog-paper": {
margin: 0,
maxWidth: "100%",
width: "100%",
height: "100vh",
overflow: "hidden", // Prevent scrollbars
},
}}
>
<Box
sx={{
position: "relative",
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: "#000", // Optional: dark background for fullscreen mode
}}
>
{/* Close Button */}
<IconButton
onClick={handleCloseFullscreen}
sx={{
position: "absolute",
top: 8,
right: 8,
zIndex: 10,
color: "white",
}}
>
<CloseIcon />
</IconButton>
{/* Fullscreen Image */}
<img
src={src}
alt={alt}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain", // Preserve aspect ratio
}}
/>
</Box>
</Dialog>
</>
);
}

View File

@ -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 (
<Card
sx={{
backgroundColor: "#1F2023",
height: isOpen ? "auto" : "150px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 16px 0px 16px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
<PollIcon
sx={{
color: "white",
}}
/>
<Typography>POLL embed</Typography>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
<ButtonBase>
<RefreshIcon
onClick={refresh}
sx={{
fontSize: "24px",
color: "white",
}}
/>
</ButtonBase>
{external && (
<ButtonBase>
<OpenInNewIcon
onClick={openExternal}
sx={{
fontSize: "24px",
color: "white",
}}
/>
</ButtonBase>
)}
</Box>
</Box>
<Box
sx={{
padding: "8px 16px 8px 16px",
}}
>
<Typography
sx={{
fontSize: "12px",
}}
>
Created by {ownerName || poll?.info?.owner}
</Typography>
</Box>
<Divider sx={{ borderColor: "rgb(255 255 255 / 10%)" }} />
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
alignItems: "center",
}}
>
{!isOpen && !errorMsg && (
<>
<Spacer height="5px" />
<Button
size="small"
variant="contained"
sx={{
backgroundColor: "var(--green)",
}}
onClick={() => {
setIsOpen(true);
}}
>
Show poll
</Button>
</>
)}
{isLoadingParent && isOpen && (
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
}}
>
{" "}
<CustomLoader />{" "}
</Box>
)}
{errorMsg && (
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
}}
>
{" "}
<Typography
sx={{
fontSize: "14px",
color: "var(--danger)",
}}
>
{errorMsg}
</Typography>{" "}
</Box>
)}
</Box>
<Box
sx={{
display: isOpen ? "block" : "none",
}}
>
<CardHeader
title={poll?.info?.pollName}
subheader={poll?.info?.description}
sx={{
"& .MuiCardHeader-title": {
fontSize: "18px", // Custom font size for title
},
}}
/>
<CardContent>
<Typography
sx={{
fontSize: "18px",
}}
>
Options
</Typography>
<RadioGroup
value={selectedOption}
onChange={(e) => setSelectedOption(e.target.value)}
>
{poll?.info?.pollOptions?.map((option, index) => (
<FormControlLabel
key={index}
value={index}
control={
<Radio
sx={{
color: "white", // Unchecked color
"&.Mui-checked": {
color: "var(--green)", // Checked color
},
}}
/>
}
label={option?.optionName}
sx={{
"& .MuiFormControlLabel-label": {
fontSize: "14px",
},
}}
/>
))}
</RadioGroup>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "20px",
}}
>
<Button
variant="contained"
color="primary"
disabled={!selectedOption || isLoadingSubmit}
onClick={handleVote}
>
Vote
</Button>
<Typography
sx={{
fontSize: "14px",
fontStyle: "italic",
}}
>
{" "}
{`${poll?.votes?.totalVotes} ${
poll?.votes?.totalVotes === 1 ? " vote" : " votes"
}`}
</Typography>
</Box>
<Spacer height="10px" />
<Typography
sx={{
fontSize: "14px",
visibility: poll?.votes?.votes?.find(
(item) => item?.voterPublicKey === userInfo?.publicKey
)
? "visible"
: "hidden",
}}
>
You've already voted.
</Typography>
<Spacer height="10px" />
{isLoadingSubmit && (
<Typography
sx={{
fontSize: "12px",
}}
>
Is processing transaction, please wait...
</Typography>
)}
<ButtonBase
onClick={() => {
setShowResults((prev) => !prev);
}}
>
{showResults ? "hide " : "show "} results
</ButtonBase>
</CardContent>
{showResults && <PollResults votes={poll?.votes} />}
</Box>
</Card>
);
};
const PollResults = ({ votes }) => {
const maxVotes = Math.max(
...votes?.voteCounts?.map((option) => option.voteCount)
);
const options = votes?.voteCounts;
return (
<Box sx={{ width: "100%", p: 2 }}>
{options
.sort((a, b) => b.voteCount - a.voteCount) // Sort options by votes (highest first)
.map((option, index) => (
<Box key={index} sx={{ mb: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography
variant="body1"
sx={{ fontWeight: index === 0 ? "bold" : "normal" , fontSize: "14px"}}
>
{`${index + 1}. ${option.optionName}`}
</Typography>
<Typography
variant="body1"
sx={{ fontWeight: index === 0 ? "bold" : "normal" , fontSize: "14px"}}
>
{option.voteCount} votes
</Typography>
</Box>
<Box
sx={{
mt: 1,
height: 10,
backgroundColor: "#e0e0e0",
borderRadius: 5,
overflow: "hidden",
}}
>
<Box
sx={{
width: `${(option.voteCount / maxVotes) * 100}%`,
height: "100%",
backgroundColor: index === 0 ? "#3f51b5" : "#f50057",
transition: "width 0.3s ease-in-out",
}}
/>
</Box>
</Box>
))}
</Box>
);
};

View File

@ -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<VideoPlayerProps> = ({
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<HTMLVideoElement | null>(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<boolean>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<VideoContainer
tabIndex={0}
onKeyUp={keyboardShortcutsUp}
onKeyDown={keyboardShortcutsDown}
style={{
padding: from === 'create' ? '8px' : 0,
width: '100%',
height: '100%',
}}
>
{isLoading && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={resourceStatus?.status === 'READY' ? '55px ' : 0}
display="flex"
justifyContent="center"
alignItems="center"
zIndex={25}
bgcolor="rgba(0, 0, 0, 0.6)"
sx={{
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}
>
<CircularProgress color="secondary" />
<Typography
variant="subtitle2"
component="div"
sx={{
color: 'white',
fontSize: '15px',
textAlign: 'center'
}}
>
{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...</>
)}
</Typography>
</Box>
)}
{((!src && !isLoading) || !startPlay) && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
display="flex"
justifyContent="center"
alignItems="center"
zIndex={500}
bgcolor="rgba(0, 0, 0, 0.6)"
onClick={() => {
togglePlay()
}}
sx={{
cursor: 'pointer'
}}
>
<PlayArrow
sx={{
width: '50px',
height: '50px',
color: 'white'
}}
/>
</Box>
)}
<Box sx={{
display: 'flex',
flexGrow: 1,
width: '100%',
height: 'calc(100% - 60px)',
}}>
<VideoElement
id={identifier}
ref={videoRef}
src={!startPlay ? '' : resourceStatus?.status === 'READY' ? src : ''}
poster={!startPlay ? poster : ""}
onTimeUpdate={updateProgress}
autoPlay={autoplay}
onClick={togglePlay}
onEnded={handleEnded}
// onLoadedMetadata={handleLoadedMetadata}
onCanPlay={handleCanPlay}
preload="metadata"
style={{
width: '100%',
height: '100%',
...customStyle
}}
/>
</Box>
<ControlsContainer
sx={{
position: 'relative',
background: 'var(--bg-primary)',
width: '100%',
flexShrink: 0
}}
>
{isMobileView && canPlay ? (
<>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={togglePlay}
>
{playing ? <Pause /> : <PlayArrow />}
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
<Slider
value={progress}
onChange={onProgressChange}
min={0}
max={videoRef.current?.duration || 100}
sx={{ flexGrow: 1, mx: 2 }}
/>
<IconButton
edge="end"
color="inherit"
aria-label="menu"
onClick={handleMenuOpen}
>
<MoreIcon />
</IconButton>
<Menu
id="simple-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleMenuClose}
PaperProps={{
style: {
width: '250px'
}
}}
>
<MenuItem>
<VolumeUp />
<Slider
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01} />
</MenuItem>
<MenuItem onClick={() => increaseSpeed()}>
<Typography
sx={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '14px'
}}
>
Speed: {playbackRate}x
</Typography>
</MenuItem>
<MenuItem onClick={toggleFullscreen}>
<Fullscreen />
</MenuItem>
</Menu>
</>
) : canPlay ? (
<>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={togglePlay}
>
{playing ? <Pause /> : <PlayArrow />}
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
<Slider
value={progress}
onChange={onProgressChange}
min={0}
max={videoRef.current?.duration || 100}
sx={{ flexGrow: 1, mx: 2, color: 'var(--Mail-Background)' }}
/>
<Typography
sx={{
fontSize: '14px',
marginRight: '5px',
color: 'rgba(255, 255, 255, 0.7)',
visibility:
!videoRef.current?.duration || !progress
? 'hidden'
: 'visible',
flexShrink: 0
}}
>
{progress && videoRef.current?.duration && formatTime(progress)}/
{progress &&
videoRef.current?.duration &&
formatTime(videoRef.current?.duration)}
</Typography>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginRight: '10px'
}}
onClick={toggleMute}
>
{isMuted ? <VolumeOff /> : <VolumeUp />}
</IconButton>
<Slider
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01}
sx={{
maxWidth: '100px',
color: 'var(--Mail-Background)'
}}
/>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '14px',
marginLeft: '5px'
}}
onClick={(e) => increaseSpeed()}
>
Speed: {playbackRate}x
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={toggleFullscreen}
>
<Fullscreen />
</IconButton>
</>
) : null}
</ControlsContainer>
</VideoContainer>
)
}

View File

@ -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 };
};

View File

@ -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 = ({
}}
>
<ListItemAvatar>
<Avatar
sx={{
{groupsProperties[group?.groupId]?.isOpen === false ? (
<Box sx={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: "#232428",
color: "white",
}}
alt={group?.groupName}
// src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${groupOwner?.name}/qortal_group_avatar_${group.groupId}?async=true`}
>
{group.groupName?.charAt(0)}
</Avatar>
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<LockIcon sx={{
color: 'var(--green)'
}} />
</Box>
): (
<Box sx={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: "#232428",
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<NoEncryptionGmailerrorredIcon sx={{
color: 'var(--danger)'
}} />
</Box>
)}
</ListItemAvatar>
<ListItemText
primary={group.groupName}
@ -2354,6 +2452,7 @@ export const Group = ({
{!isMobile && selectedGroup && (
<DesktopHeader
isPrivate={isPrivate}
selectedGroup={selectedGroup}
groupSection={groupSection}
isUnread={isUnread}
@ -2469,6 +2568,7 @@ export const Group = ({
>
{triedToFetchSecretKey && (
<ChatGroup
isPrivate={isPrivate}
myAddress={myAddress}
selectedGroup={selectedGroup?.groupId}
getSecretKey={getSecretKey}
@ -2477,16 +2577,18 @@ export const Group = ({
handleNewEncryptionNotification={
setNewEncryptionNotification
}
hide={groupSection !== "chat" || !secretKey}
hide={groupSection !== "chat" || selectedDirect || newChat}
// hideView={!(desktopViewMode === 'chat' && selectedGroup)}
handleSecretKeyCreationInProgress={
handleSecretKeyCreationInProgress
}
triedToFetchSecretKey={triedToFetchSecretKey}
myName={userInfo?.name}
balance={balance}
getTimestampEnterChatParent={getTimestampEnterChat}
/>
)}
{firstSecretKeyInCreation &&
{isPrivate && firstSecretKeyInCreation &&
triedToFetchSecretKey &&
!secretKeyPublishDate && (
<div
@ -2507,7 +2609,7 @@ export const Group = ({
</Typography>
</div>
)}
{!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 : (
<>
<GroupAnnouncements
isPrivate={isPrivate}
myAddress={myAddress}
selectedGroup={selectedGroup?.groupId}
getSecretKey={getSecretKey}
@ -2577,6 +2680,7 @@ export const Group = ({
hide={groupSection !== "announcement"}
/>
<GroupForum
isPrivate={isPrivate}
myAddress={myAddress}
selectedGroup={selectedGroup}
userInfo={userInfo}
@ -2600,11 +2704,11 @@ export const Group = ({
zIndex: 100,
}}
>
{admins.includes(myAddress) &&
{((isPrivate && admins.includes(myAddress) &&
shouldReEncrypt &&
triedToFetchSecretKey &&
!firstSecretKeyInCreation &&
!hideCommonKeyPopup && (
!hideCommonKeyPopup) || isForceShowCreationKeyPopup) && (
<CreateCommonSecret
setHideCommonKeyPopup={setHideCommonKeyPopup}
groupId={selectedGroup?.groupId}
@ -2678,6 +2782,7 @@ export const Group = ({
)}
{!isMobile && groupSection === "home" && (
<DesktopFooter
isPrivate={isPrivate}
selectedGroup={selectedGroup}
groupSection={groupSection}
isUnread={isUnread}

View File

@ -311,54 +311,7 @@ export const ListOfGroupPromotions = () => {
}
};
// 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];

View File

@ -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 (
<>
<ButtonBase onClick={saveToQdn} disabled={!hasChanged || !canSave || isLoading || settingsQdnLastUpdated === -100}>
{isDesktop ? (
<ButtonBase
onClick={handlePopupClick}
disabled={
// !hasChanged ||
// !canSave ||
isLoading
// settingsQdnLastUpdated === -100
}
>
{isDesktop ? (
<IconWrapper
color="rgba(250, 250, 250, 0.5)"
label="Save"
selected={false}
>
<SaveIcon
color={settingsQdnLastUpdated === -100 ? '#8F8F91' : (hasChanged && !isLoading) ? '#5EB049' : '#8F8F91'}
/>
disableWidth={disableWidth}
color="rgba(250, 250, 250, 0.5)"
label="Save"
selected={false}
>
<SaveIcon
color={
settingsQdnLastUpdated === -100
? "#8F8F91"
: hasChanged && !isLoading
? "#5EB049"
: "#8F8F91"
}
/>
</IconWrapper>
) : (
<SaveIcon
color={settingsQdnLastUpdated === -100 ? '#8F8F91' : (hasChanged && !isLoading) ? '#5EB049' : '#8F8F91'}
/>
)}
) : (
<SaveIcon
color={
settingsQdnLastUpdated === -100
? "#8F8F91"
: hasChanged && !isLoading
? "#5EB049"
: "#8F8F91"
}
/>
)}
</ButtonBase>
<CustomizedSnackbars
<Popover
open={!!anchorEl}
anchorEl={anchorEl}
onClose={() => 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 && (
<Box
sx={{
padding: "15px",
display: "flex",
flexDirection: "column",
gap: 1,
width: '100%'
}}
>
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Typography
sx={{
fontSize: "14px",
}}
>
You are using the export/import way of saving settings.
</Typography>
<Spacer height="40px" />
<Button
size="small"
onClick={()=> {
saveToLocalStorage("ext_saved_settings_import_export", "sortablePinnedApps", null, true);
setIsUsingImportExportSettings(false)
}}
variant="contained"
sx={{
backgroundColor: "var(--danger)",
color: "black",
fontWeight: 'bold',
opacity: 0.7,
"&:hover": {
backgroundColor: "var(--danger)",
color: "black",
opacity: 1,
},
}}
>
Use QDN saving
</Button>
</Box>
</Box>
)}
{!isUsingImportExportSettings && (
<Box
sx={{
padding: "15px",
display: "flex",
flexDirection: "column",
gap: 1,
width: '100%'
}}
>
{!myName ? (
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Typography
sx={{
fontSize: "14px",
}}
>
You need a registered Qortal name to save your pinned apps to QDN.
</Typography>
</Box>
) : (
<>
{hasChanged && (
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Typography
sx={{
fontSize: "14px",
}}
>
You have unsaved changes to your pinned apps. Save them to QDN.
</Typography>
<Spacer height="10px" />
<LoadingButton
sx={{
backgroundColor: "var(--green)",
color: "black",
opacity: 0.7,
fontWeight: 'bold',
"&:hover": {
backgroundColor: "var(--green)",
color: "black",
opacity: 1,
},
}}
size="small"
loading={isLoading}
onClick={saveToQdn}
variant="contained"
>
Save to QDN
</LoadingButton>
<Spacer height="20px" />
{!isNaN(settingsQdnLastUpdated) && settingsQdnLastUpdated > 0 && (
<>
<Typography
sx={{
fontSize: "14px",
}}
>
Don't like your current local changes? Would you like to
reset to your saved QDN pinned apps?
</Typography>
<Spacer height="10px" />
<LoadingButton
size="small"
loading={isLoading}
onClick={revertChanges}
variant="contained"
sx={{
backgroundColor: "var(--danger)",
color: "black",
fontWeight: 'bold',
opacity: 0.7,
"&:hover": {
backgroundColor: "var(--danger)",
color: "black",
opacity: 1,
},
}}
>
Revert to QDN
</LoadingButton>
</>
)}
{!isNaN(settingsQdnLastUpdated) && settingsQdnLastUpdated === 0 && (
<>
<Typography
sx={{
fontSize: "14px",
}}
>
Don't like your current local changes? Would you like to
reset to the default pinned apps?
</Typography>
<Spacer height="10px" />
<LoadingButton
loading={isLoading}
onClick={revertChanges}
variant="contained"
>
Revert to default
</LoadingButton>
</>
)}
</Box>
)}
{!isNaN(settingsQdnLastUpdated) && settingsQdnLastUpdated === -100 && isUsingImportExportSettings !== true && (
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Typography
sx={{
fontSize: "14px",
}}
>
The app was unable to download your existing QDN-saved pinned
apps. Would you like to overwrite those changes?
</Typography>
<Spacer height="10px" />
<LoadingButton
size="small"
loading={isLoading}
onClick={saveToQdn}
variant="contained"
sx={{
backgroundColor: "var(--danger)",
color: "black",
fontWeight: 'bold',
opacity: 0.7,
"&:hover": {
backgroundColor: "var(--danger)",
color: "black",
opacity: 1,
},
}}
>
Overwrite to QDN
</LoadingButton>
</Box>
)}
{!hasChanged && (
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Typography
sx={{
fontSize: "14px",
}}
>
You currently do not have any changes to your pinned apps
</Typography>
</Box>
)}
</>
)}
</Box>
)}
<Box
sx={{
padding: "15px",
display: "flex",
flexDirection: "column",
gap: 1,
width: '100%'
}}
>
<Box sx={{
display: 'flex',
gap: '10px',
justifyContent: 'flex-end',
width: '100%'
}}>
<ButtonBase onClick={async () => {
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
</ButtonBase>
<ButtonBase onClick={async () => {
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
</ButtonBase>
</Box>
</Box>
</Popover>
<CustomizedSnackbars
duration={3500}
open={openSnack}
setOpen={setOpenSnack}
@ -156,6 +605,5 @@ export const Save = ({isDesktop}) => {
setInfo={setInfoSnack}
/>
</>
)
}
);
};

View File

@ -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 (
<Dialog
onClose={handleClose}
aria-labelledby="customized-dialog-title"
open={!!openTutorialModal}
fullWidth={true}
maxWidth="xl"
>
<Tabs sx={{
"& .MuiTabs-indicator": {
backgroundColor: "white",
},
}} value={multiNumber} onChange={(e, value)=> setMultiNumber(value)} aria-label="basic tabs example">
{openTutorialModal?.multi?.map((item, index)=> {
return (
<Tab sx={{
"&.Mui-selected": {
color: "white",
},
}} label={item?.title} value={index} />
)
})}
</Tabs>
<DialogTitle sx={{ m: 0, p: 2 }} >
{selectedTutorial?.title} {` Tutorial`}
</DialogTitle>
<IconButton
aria-label="close"
onClick={handleClose}
sx={(theme) => ({
position: 'absolute',
right: 8,
top: 8,
color: theme.palette.grey[500],
})}
>
<CloseIcon />
</IconButton>
<DialogContent dividers sx={{
height: '85vh'
}}>
<VideoPlayer node="https://ext-node.qortal.link" {...selectedTutorial?.resource || {}} />
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={handleClose}>
Close
</Button>
</DialogActions>
</Dialog>
)
}
return (
<>
<Dialog
onClose={handleClose}
aria-labelledby="customized-dialog-title"
open={!!openTutorialModal}
fullWidth={true}
maxWidth="xl"
>
<DialogTitle sx={{ m: 0, p: 2 }} >
{openTutorialModal?.title} {` Tutorial`}
</DialogTitle>
<IconButton
aria-label="close"
onClick={handleClose}
sx={(theme) => ({
position: 'absolute',
right: 8,
top: 8,
color: theme.palette.grey[500],
})}
>
<CloseIcon />
</IconButton>
<DialogContent dividers sx={{
height: '85vh'
}}>
<VideoPlayer node="https://ext-node.qortal.link" {...openTutorialModal?.resource || {}} />
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={handleClose}>
Close
</Button>
</DialogActions>
</Dialog>
</>
)
}

View File

@ -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<any>(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
};
};

View File

@ -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: "<color>";
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 {

View File

@ -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)
}
}
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);
};

View File

@ -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;

View File

@ -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");
}
};

View File

@ -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])
}

View File

@ -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])
}

16
src/utils/decode.ts Normal file
View File

@ -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;
}

View File

@ -54,4 +54,14 @@ export const fileToBase64 = (file) => new Promise(async (resolve, reject) => {
reject(error)
semaphore.release()
}
})
})
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);
};

View File

@ -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);
}

View File

@ -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",
}
"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",
};