mirror of
https://github.com/Qortal/chrome-extension.git
synced 2025-02-11 17:55:49 +00:00
batch of updates 1
This commit is contained in:
parent
f464a5b049
commit
d0719a30af
291
package-lock.json
generated
291
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
75
src/App.tsx
75
src/App.tsx
@ -103,9 +103,13 @@ import { MainAvatar } from "./components/MainAvatar";
|
||||
import { useRetrieveDataLocalStorage } from "./useRetrieveDataLocalStorage";
|
||||
import { useQortalGetSaveSettings } from "./useQortalGetSaveSettings";
|
||||
import { useRecoilState, useResetRecoilState } from "recoil";
|
||||
import { canSaveSettingToQdnAtom, fullScreenAtom, hasSettingsChangedAtom, oldPinnedAppsAtom, settingsLocalLastUpdatedAtom, settingsQDNLastUpdatedAtom, sortablePinnedAppsAtom } from "./atoms/global";
|
||||
import { canSaveSettingToQdnAtom, fullScreenAtom, hasSettingsChangedAtom, isUsingImportExportSettingsAtom, oldPinnedAppsAtom, settingsLocalLastUpdatedAtom, settingsQDNLastUpdatedAtom, sortablePinnedAppsAtom } from "./atoms/global";
|
||||
import { useAppFullScreen } from "./useAppFullscreen";
|
||||
import { NotAuthenticated } from "./ExtStates/NotAuthenticated";
|
||||
import { useFetchResources } from "./common/useFetchResources";
|
||||
import { Tutorials } from "./components/Tutorials/Tutorials";
|
||||
import { useHandleTutorials } from "./components/Tutorials/useHandleTutorials";
|
||||
import { CoreSyncStatus } from "./components/CoreSyncStatus";
|
||||
|
||||
type extStates =
|
||||
| "not-authenticated"
|
||||
@ -217,8 +221,12 @@ export const resumeAllQueues = () => {
|
||||
payload: {},
|
||||
});
|
||||
};
|
||||
|
||||
const defaultValuesGlobal = {
|
||||
openTutorialModal: null,
|
||||
setOpenTutorialModal: ()=> {}
|
||||
}
|
||||
export const MyContext = createContext<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>
|
||||
<AuthenticatedContainerInnerRight sx={{
|
||||
height: "100%",
|
||||
justifyContent: "space-between",
|
||||
}}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Spacer height="20px" />
|
||||
<img
|
||||
onClick={() => {
|
||||
setExtstate("download-wallet");
|
||||
setIsOpenDrawerProfile(false);
|
||||
}}
|
||||
src={Download}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
|
||||
{!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,6 +1590,13 @@ 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>
|
||||
);
|
||||
}
|
||||
|
@ -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,58 +39,36 @@ 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) => {
|
||||
// Internal function to handle queue processing
|
||||
const processQueueInternal = async (newMessages, groupDirectId) => {
|
||||
// Remove any messages from the queue that match the specialId from newMessages
|
||||
|
||||
return !newMessages.some(newMsg => newMsg?.specialId === chat?.message?.specialId);
|
||||
});
|
||||
// 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'
|
||||
@ -105,20 +86,13 @@ export const MessageQueueProvider = ({ children }) => {
|
||||
});
|
||||
|
||||
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 the queue after successful sending
|
||||
messageQueueRef.current.shift();
|
||||
|
||||
// Remove the message from queueChats after success
|
||||
// setQueueChats((prev) => {
|
||||
// const updatedChats = { ...prev };
|
||||
// updatedChats[groupDirectId] = updatedChats[groupDirectId].filter(
|
||||
// (item) => item.identifier !== identifier
|
||||
// );
|
||||
// return updatedChats;
|
||||
// });
|
||||
} catch (error) {
|
||||
console.error('Message sending failed', error);
|
||||
|
||||
@ -138,26 +112,74 @@ export const MessageQueueProvider = ({ children }) => {
|
||||
// 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]) {
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
// Reset the processing lock once all messages are processed
|
||||
isProcessingRef.current = false;
|
||||
}, []);
|
||||
return updatedChats
|
||||
}
|
||||
)
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return updatedNewMessages
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageQueueContext.Provider value={{ addToQueue, queueChats, clearStatesMessageQueueProvider, processWithNewMessages }}>
|
||||
|
BIN
src/assets/syncStatus/synced.png
Normal file
BIN
src/assets/syncStatus/synced.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/syncStatus/synced_minting.png
Normal file
BIN
src/assets/syncStatus/synced_minting.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/syncStatus/syncing.png
Normal file
BIN
src/assets/syncStatus/syncing.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
@ -1,4 +1,4 @@
|
||||
import { atom } from 'recoil';
|
||||
import { atom, selectorFamily } from 'recoil';
|
||||
|
||||
|
||||
export const sortablePinnedAppsAtom = atom({
|
||||
@ -89,3 +89,39 @@ 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,
|
||||
});
|
@ -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}`;
|
||||
|
36
src/common/ErrorBoundary.tsx
Normal file
36
src/common/ErrorBoundary.tsx
Normal 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
|
168
src/common/useFetchResources.tsx
Normal file
168
src/common/useFetchResources.tsx
Normal 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 };
|
||||
};
|
@ -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',
|
||||
|
@ -366,7 +366,7 @@ export const AppsDesktop = ({ mode, setMode, show , myName, goToHome, setDesktop
|
||||
/>
|
||||
|
||||
</ButtonBase>
|
||||
<Save isDesktop />
|
||||
<Save isDesktop myName={myName} />
|
||||
{mode !== 'home' && (
|
||||
<AppsNavBarDesktop />
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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) {
|
||||
if (!decryptResponse?.error) {
|
||||
const response = processWithNewMessages(decryptResponse, selectedDirect?.address);
|
||||
res(response);
|
||||
|
||||
processWithNewMessages(response, selectedDirect?.address)
|
||||
|
||||
res(response)
|
||||
if(isInitiated){
|
||||
|
||||
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
|
||||
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){
|
||||
|
||||
}
|
||||
} )
|
||||
setMessages((prev)=> [...prev, ...formatted])
|
||||
})
|
||||
return organizedChatReferences
|
||||
})
|
||||
} else {
|
||||
const formatted = response.map((item: any)=> {
|
||||
return {
|
||||
hasInitialized.current = true;
|
||||
const formatted = response.filter((rawItem) => !rawItem?.chatReference)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
id: item.signature,
|
||||
text: item.message,
|
||||
unread: false
|
||||
}
|
||||
} )
|
||||
setMessages(formatted)
|
||||
hasInitialized.current = true
|
||||
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,9 +346,28 @@ 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 ()=> {
|
||||
// Add a listener for the editorRef?.current's content updates
|
||||
editorRef?.current.on('update', handleUpdate);
|
||||
|
||||
// Cleanup the listener on unmount
|
||||
return () => {
|
||||
editorRef?.current.off('update', handleUpdate);
|
||||
};
|
||||
}, [editorRef?.current]);
|
||||
|
||||
|
||||
const sendMessage = async ()=> {
|
||||
try {
|
||||
if(messageSize > 4000) return
|
||||
|
||||
|
||||
if(+balance < 4) throw new Error('You need at least 4 QORT to send a message')
|
||||
@ -330,12 +390,16 @@ const clearEditorContent = () => {
|
||||
if (replyMessage?.chatReference) {
|
||||
repliedTo = replyMessage?.chatReference
|
||||
}
|
||||
let chatReference = onEditMessage?.signature
|
||||
|
||||
const otherData = {
|
||||
...(onEditMessage?.decryptedData || {}),
|
||||
specialId: uid.rnd(),
|
||||
repliedTo
|
||||
repliedTo: onEditMessage ? onEditMessage?.repliedTo : repliedTo,
|
||||
type: chatReference ? 'edit' : ''
|
||||
}
|
||||
const sendMessageFunc = async () => {
|
||||
await sendChatDirect({ chatReference: undefined, messageText: htmlContent, otherData}, selectedDirect?.address, publicKeyOfRecipient, false)
|
||||
return await sendChatDirect({ chatReference, messageText: htmlContent, otherData}, selectedDirect?.address, publicKeyOfRecipient, false)
|
||||
};
|
||||
|
||||
|
||||
@ -343,13 +407,13 @@ const clearEditorContent = () => {
|
||||
// Add the function to the queue
|
||||
const messageObj = {
|
||||
message: {
|
||||
text: htmlContent,
|
||||
timestamp: Date.now(),
|
||||
senderName: myName,
|
||||
sender: myAddress,
|
||||
...(otherData || {})
|
||||
...(otherData || {}),
|
||||
text: htmlContent,
|
||||
},
|
||||
|
||||
chatReference
|
||||
}
|
||||
addToQueue(sendMessageFunc, messageObj, 'chat-direct',
|
||||
selectedDirect?.address );
|
||||
@ -358,6 +422,8 @@ const clearEditorContent = () => {
|
||||
}, 150);
|
||||
clearEditorContent()
|
||||
setReplyMessage(null)
|
||||
setOnEditMessage(null)
|
||||
|
||||
}
|
||||
// send chat message
|
||||
} catch (error) {
|
||||
@ -372,13 +438,24 @@ const clearEditorContent = () => {
|
||||
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',
|
||||
|
@ -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
|
||||
};
|
||||
@ -122,7 +192,6 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
|
||||
try {
|
||||
if(!secretKeyRef.current){
|
||||
checkForFirstSecretKeyNotification(encryptedMessages)
|
||||
return
|
||||
}
|
||||
return new Promise((res, rej)=> {
|
||||
chrome?.runtime?.sendMessage({ action: "decryptSingle", payload: {
|
||||
@ -130,163 +199,188 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
|
||||
secretKeyObject: secretKey
|
||||
}}, (response) => {
|
||||
if (!response?.error) {
|
||||
const filterUImessages = encryptedMessages.filter((item)=> !isExtMsg(item.data))
|
||||
const decodedUIMessages = decodeBase64ForUIChatMessages(filterUImessages)
|
||||
const filterUIMessages = encryptedMessages.filter((item) => !isExtMsg(item.data));
|
||||
const decodedUIMessages = decodeBase64ForUIChatMessages(filterUIMessages);
|
||||
|
||||
const combineUIAndExtensionMsgs = [...decodedUIMessages, ...response]
|
||||
processWithNewMessages(combineUIAndExtensionMsgs?.map((item)=> {
|
||||
return {
|
||||
const combineUIAndExtensionMsgsBefore = [...decodedUIMessages, ...response];
|
||||
const combineUIAndExtensionMsgs = processWithNewMessages(
|
||||
combineUIAndExtensionMsgsBefore.map((item) => ({
|
||||
...item,
|
||||
...(item?.decryptedData || {})
|
||||
}
|
||||
}), selectedGroup)
|
||||
res(combineUIAndExtensionMsgs)
|
||||
if(isInitiated){
|
||||
...(item?.decryptedData || {}),
|
||||
})),
|
||||
selectedGroup
|
||||
);
|
||||
res(combineUIAndExtensionMsgs);
|
||||
|
||||
const formatted = combineUIAndExtensionMsgs.filter((rawItem)=> !rawItem?.chatReference).map((item: any)=> {
|
||||
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
|
||||
isNotEncrypted: !!item?.messageText,
|
||||
...additionalFields
|
||||
}
|
||||
} )
|
||||
setMessages((prev)=> [...prev, ...formatted])
|
||||
|
||||
});
|
||||
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;
|
||||
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;
|
||||
const contentState = item?.contentState || item.decryptedData?.contentState;
|
||||
|
||||
if (!content || typeof content !== 'string' || !sender || typeof sender !== 'string' || !newTimestamp) {
|
||||
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].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);
|
||||
console.error("Error processing reaction/edit item:", error, item);
|
||||
}
|
||||
});
|
||||
|
||||
return organizedChatReferences;
|
||||
});
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
const formatted = combineUIAndExtensionMsgs.filter((rawItem)=> !rawItem?.chatReference).map((item: any)=> {
|
||||
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
|
||||
unread: false,
|
||||
divide,
|
||||
...additionalFields
|
||||
}
|
||||
} )
|
||||
setMessages(formatted)
|
||||
});
|
||||
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;
|
||||
const contentState = item?.contentState || item.decryptedData?.contentState;
|
||||
|
||||
if (!content || typeof content !== 'string' || !sender || typeof sender !== 'string' || !newTimestamp) {
|
||||
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].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);
|
||||
}
|
||||
@ -294,14 +388,9 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
|
||||
|
||||
return organizedChatReferences;
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
rej(response.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,8 +559,10 @@ const clearEditorContent = () => {
|
||||
};
|
||||
|
||||
|
||||
const sendMessage = async ()=> {
|
||||
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()
|
||||
@ -478,8 +570,10 @@ const clearEditorContent = () => {
|
||||
const htmlContent = editorRef.current.getHTML();
|
||||
|
||||
if(!htmlContent?.trim() || htmlContent?.trim() === '<p></p>') return
|
||||
|
||||
|
||||
setIsSending(true)
|
||||
const message = htmlContent
|
||||
const message = isPrivate === false ? editorRef.current.getJSON() : htmlContent
|
||||
const secretKeyObject = await getSecretKey(false, true)
|
||||
|
||||
let repliedTo = replyMessage?.signature
|
||||
@ -487,33 +581,42 @@ const clearEditorContent = () => {
|
||||
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(),
|
||||
repliedTo
|
||||
...publicData
|
||||
}
|
||||
const objectMessage = {
|
||||
message,
|
||||
...(otherData || {})
|
||||
...(otherData || {}),
|
||||
[isPrivate ? 'message' : 'messageText']: message,
|
||||
version: 3
|
||||
}
|
||||
const message64: any = await objectToBase64(objectMessage)
|
||||
|
||||
const encryptSingle = await encryptChatMessage(message64, secretKeyObject)
|
||||
const encryptSingle = isPrivate === false ? JSON.stringify(objectMessage) : await encryptChatMessage(message64, secretKeyObject)
|
||||
// const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle})
|
||||
|
||||
const sendMessageFunc = async () => {
|
||||
await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle})
|
||||
return await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle, chatReference})
|
||||
};
|
||||
|
||||
// Add the function to the queue
|
||||
const messageObj = {
|
||||
message: {
|
||||
text: message,
|
||||
text: htmlContent,
|
||||
timestamp: Date.now(),
|
||||
senderName: myName,
|
||||
sender: myAddress,
|
||||
...(otherData || {})
|
||||
},
|
||||
|
||||
chatReference
|
||||
}
|
||||
addToQueue(sendMessageFunc, messageObj, 'chat',
|
||||
selectedGroup );
|
||||
@ -522,6 +625,7 @@ const clearEditorContent = () => {
|
||||
}, 150);
|
||||
clearEditorContent()
|
||||
setReplyMessage(null)
|
||||
setOnEditMessage(null)
|
||||
}
|
||||
// send chat message
|
||||
} catch (error) {
|
||||
@ -536,8 +640,40 @@ const clearEditorContent = () => {
|
||||
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) {
|
||||
setTimeout(() => setIsMoved(true), 500); // Wait for the fade-out to complete before moving
|
||||
@ -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
|
||||
@ -617,6 +757,8 @@ const clearEditorContent = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
console.log('isPrivate', isPrivate)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: isMobile ? '100%' : '100%',
|
||||
@ -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} />
|
||||
</div>
|
||||
<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&',
|
||||
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: '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."
|
||||
|
@ -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,29 +108,39 @@ 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) {
|
||||
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(() => {
|
||||
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,25 +178,24 @@ 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 (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@ -160,28 +221,40 @@ export const ChatList = ({
|
||||
top: 0,
|
||||
left: 0,
|
||||
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(
|
||||
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
|
||||
);
|
||||
let reply;
|
||||
let reactions = null;
|
||||
|
||||
if (message?.repliedTo && replyIndex !== -1) {
|
||||
reply = messages[replyIndex];
|
||||
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];
|
||||
reply = messages[replyIndex] || null;
|
||||
}
|
||||
message = {
|
||||
...(message?.message || {}),
|
||||
@ -191,26 +264,64 @@ export const ChatList = ({
|
||||
};
|
||||
}
|
||||
|
||||
if (chatReferences && chatReferences[message?.signature]) {
|
||||
if (chatReferences[message.signature]?.reactions) {
|
||||
reactions = chatReferences[message.signature]?.reactions;
|
||||
// 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
|
||||
}
|
||||
|
||||
let isUpdating = false;
|
||||
}
|
||||
|
||||
// Check if message is updating
|
||||
if (
|
||||
tempChatReferences &&
|
||||
tempChatReferences?.find(
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-index={virtualRow.index} //needed for dynamic row height measurement
|
||||
ref={(node) => rowVirtualizer.measureElement(node)} //measure dynamic row height
|
||||
ref={rowVirtualizer.measureElement} //measure dynamic row height
|
||||
key={message.signature}
|
||||
style={{
|
||||
position: "absolute",
|
||||
@ -220,9 +331,18 @@ export const ChatList = ({
|
||||
width: "100%", // Control width (90% of the parent)
|
||||
padding: "10px 0",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
overscrollBehavior: "none",
|
||||
flexDirection: "column",
|
||||
gap: "5px",
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<Typography>
|
||||
Error loading content: Invalid Data
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
<MessageItem
|
||||
isLast={index === messages.length - 1}
|
||||
@ -232,16 +352,20 @@ export const ChatList = ({
|
||||
isTemp={!!message?.isTemp}
|
||||
myAddress={myAddress}
|
||||
onReply={onReply}
|
||||
onEdit={onEdit}
|
||||
reply={reply}
|
||||
replyIndex={replyIndex}
|
||||
scrollToItem={(idx) => rowVirtualizer.scrollToIndex(idx)}
|
||||
scrollToItem={goToMessage}
|
||||
handleReaction={handleReaction}
|
||||
reactions={reactions}
|
||||
isUpdating={isUpdating}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -249,20 +373,55 @@ export const ChatList = ({
|
||||
<button
|
||||
onClick={() => scrollToBottom()}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 20,
|
||||
position: "absolute",
|
||||
right: 20,
|
||||
backgroundColor: "#ff5a5f",
|
||||
color: "white",
|
||||
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>
|
||||
{enableMentions && (hasSecretKey || isPrivate === false) && (
|
||||
<ChatOptions
|
||||
openQManager={openQManager}
|
||||
messages={messages}
|
||||
goToMessage={goToMessage}
|
||||
members={members}
|
||||
myName={myName}
|
||||
selectedGroup={selectedGroup}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
718
src/components/Chat/ChatOptions.tsx
Normal file
718
src/components/Chat/ChatOptions.tsx
Normal 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>
|
||||
);
|
||||
};
|
69
src/components/Chat/MentionList.tsx
Normal file
69
src/components/Chat/MentionList.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
|
@ -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;
|
||||
@ -64,98 +70,60 @@ export const MessageDisplay = ({ htmlContent, isReply }) => {
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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,17 +255,15 @@ 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={{
|
||||
@ -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'
|
||||
}}>
|
||||
{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
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
|
@ -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
|
||||
@ -348,5 +469,7 @@ export default ({
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
139
src/components/ContextMenuMentions.tsx
Normal file
139
src/components/ContextMenuMentions.tsx
Normal 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>
|
||||
);
|
||||
};
|
59
src/components/CoreSyncStatus.css
Normal file
59
src/components/CoreSyncStatus.css
Normal 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);
|
||||
}
|
113
src/components/CoreSyncStatus.tsx
Normal file
113
src/components/CoreSyncStatus.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
327
src/components/Embeds/AttachmentEmbed.tsx
Normal file
327
src/components/Embeds/AttachmentEmbed.tsx
Normal 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>
|
||||
);
|
||||
};
|
18
src/components/Embeds/Embed-styles.tsx
Normal file
18
src/components/Embeds/Embed-styles.tsx
Normal 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",
|
||||
}));
|
406
src/components/Embeds/Embed.tsx
Normal file
406
src/components/Embeds/Embed.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
265
src/components/Embeds/ImageEmbed.tsx
Normal file
265
src/components/Embeds/ImageEmbed.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
395
src/components/Embeds/PollEmbed.tsx
Normal file
395
src/components/Embeds/PollEmbed.tsx
Normal 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>
|
||||
);
|
||||
};
|
723
src/components/Embeds/VideoPlayer.tsx
Normal file
723
src/components/Embeds/VideoPlayer.tsx
Normal 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>
|
||||
)
|
||||
}
|
40
src/components/Embeds/embed-utils.ts
Normal file
40
src/components/Embeds/embed-utils.ts
Normal 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 };
|
||||
};
|
@ -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) {
|
||||
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 &&
|
||||
(!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}
|
||||
|
@ -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];
|
||||
|
@ -1,52 +1,113 @@
|
||||
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}) => {
|
||||
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";
|
||||
|
||||
|
||||
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 [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 [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 hasChanged = useMemo(()=> {
|
||||
const hasChanged = useMemo(() => {
|
||||
const newChanges = {
|
||||
sortablePinnedApps: pinnedApps.map((item)=> {
|
||||
sortablePinnedApps: pinnedApps.map((item) => {
|
||||
return {
|
||||
name: item?.name,
|
||||
service: item?.service
|
||||
}
|
||||
})
|
||||
}
|
||||
service: item?.service,
|
||||
};
|
||||
}),
|
||||
};
|
||||
const oldChanges = {
|
||||
sortablePinnedApps: oldPinnedApps.map((item)=> {
|
||||
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])
|
||||
service: item?.service,
|
||||
};
|
||||
}),
|
||||
};
|
||||
if (settingsQdnLastUpdated === -100) return false;
|
||||
return (
|
||||
!isEqual(oldChanges, newChanges) &&
|
||||
settingsQdnLastUpdated < settingsLocalLastUpdated
|
||||
);
|
||||
}, [
|
||||
oldPinnedApps,
|
||||
pinnedApps,
|
||||
settingsQdnLastUpdated,
|
||||
settingsLocalLastUpdated,
|
||||
]);
|
||||
|
||||
useEffect(()=> {
|
||||
setHasSettingsChangedAtom(hasChanged)
|
||||
}, [hasChanged])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setHasSettingsChangedAtom(hasChanged);
|
||||
}, [hasChanged]);
|
||||
|
||||
const saveToQdn = async ()=> {
|
||||
try {
|
||||
@ -128,26 +189,414 @@ export const Save = ({isDesktop}) => {
|
||||
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}>
|
||||
<ButtonBase
|
||||
onClick={handlePopupClick}
|
||||
disabled={
|
||||
// !hasChanged ||
|
||||
// !canSave ||
|
||||
isLoading
|
||||
// settingsQdnLastUpdated === -100
|
||||
}
|
||||
>
|
||||
{isDesktop ? (
|
||||
<IconWrapper
|
||||
disableWidth={disableWidth}
|
||||
color="rgba(250, 250, 250, 0.5)"
|
||||
label="Save"
|
||||
selected={false}
|
||||
>
|
||||
<SaveIcon
|
||||
color={settingsQdnLastUpdated === -100 ? '#8F8F91' : (hasChanged && !isLoading) ? '#5EB049' : '#8F8F91'}
|
||||
color={
|
||||
settingsQdnLastUpdated === -100
|
||||
? "#8F8F91"
|
||||
: hasChanged && !isLoading
|
||||
? "#5EB049"
|
||||
: "#8F8F91"
|
||||
}
|
||||
/>
|
||||
</IconWrapper>
|
||||
) : (
|
||||
<SaveIcon
|
||||
color={settingsQdnLastUpdated === -100 ? '#8F8F91' : (hasChanged && !isLoading) ? '#5EB049' : '#8F8F91'}
|
||||
color={
|
||||
settingsQdnLastUpdated === -100
|
||||
? "#8F8F91"
|
||||
: hasChanged && !isLoading
|
||||
? "#5EB049"
|
||||
: "#8F8F91"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
</ButtonBase>
|
||||
<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}
|
||||
@ -156,6 +605,5 @@ export const Save = ({isDesktop}) => {
|
||||
setInfo={setInfoSnack}
|
||||
/>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
108
src/components/Tutorials/Tutorials.tsx
Normal file
108
src/components/Tutorials/Tutorials.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
169
src/components/Tutorials/useHandleTutorials.tsx
Normal file
169
src/components/Tutorials/useHandleTutorials.tsx
Normal 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
|
||||
};
|
||||
};
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
||||
}
|
||||
@ -208,8 +224,7 @@ export const decodeBase64ForUIChatMessages = (messages)=> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
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"
|
||||
@ -421,3 +459,43 @@ export function decryptDeprecatedSingle(uint8Array, publicKey, privateKey) {
|
||||
}
|
||||
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);
|
||||
};
|
@ -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;
|
||||
|
@ -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 = '';
|
||||
let apiEndpoint = "";
|
||||
let method = "GET"; // Default method
|
||||
let includeValueInBody = false;
|
||||
switch (data.type.toLowerCase()) {
|
||||
case 'stop':
|
||||
apiEndpoint = await createEndpoint('/admin/stop');
|
||||
case "stop":
|
||||
apiEndpoint = await createEndpoint("/admin/stop");
|
||||
break;
|
||||
case 'restart':
|
||||
apiEndpoint = await createEndpoint('/admin/restart');
|
||||
case "restart":
|
||||
apiEndpoint = await createEndpoint("/admin/restart");
|
||||
break;
|
||||
case 'bootstrap':
|
||||
apiEndpoint = await createEndpoint('/admin/bootstrap');
|
||||
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 resPermission = await getUserPermission(
|
||||
{
|
||||
text1: permissionText,
|
||||
},
|
||||
isFromExtension
|
||||
);
|
||||
const { accepted } = resPermission;
|
||||
if (accepted) {
|
||||
const response = await fetch(apiEndpoint);
|
||||
// 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");
|
||||
}
|
||||
};
|
@ -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])
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}, [])
|
||||
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
16
src/utils/decode.ts
Normal 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;
|
||||
}
|
@ -55,3 +55,13 @@ export const fileToBase64 = (file) => new Promise(async (resolve, reject) => {
|
||||
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);
|
||||
};
|
@ -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);
|
||||
|
||||
}
|
@ -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",
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user