diff --git a/index.html b/index.html index 2dab011..4745170 100644 --- a/index.html +++ b/index.html @@ -1,7 +1,8 @@ - + + Qortal Extension diff --git a/package-lock.json b/package-lock.json index 1de9879..18b6b78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,15 @@ "version": "0.0.0", "dependencies": { "@chatscope/chat-ui-kit-react": "^2.0.3", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.16.4", "@mui/lab": "^5.0.0-alpha.173", "@mui/material": "^5.16.7", "@reduxjs/toolkit": "^2.2.7", + "@tanstack/react-virtual": "^3.10.8", "@testing-library/jest-dom": "^6.4.6", "@testing-library/user-event": "^14.5.2", "@tiptap/extension-color": "^2.5.9", @@ -45,6 +48,7 @@ "react-countdown-circle-timer": "^3.2.1", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", + "react-frame-component": "^5.2.7", "react-infinite-scroller": "^1.2.6", "react-intersection-observer": "^9.13.0", "react-qr-code": "^2.0.15", @@ -52,6 +56,7 @@ "react-redux": "^9.1.2", "react-virtualized": "^9.22.5", "react-virtuoso": "^4.10.4", + "recoil": "^0.7.7", "short-unique-id": "^5.2.0", "slate": "^0.103.0", "slate-react": "^0.109.0", @@ -483,6 +488,55 @@ "resolved": "https://registry.npmjs.org/@chatscope/chat-ui-kit-styles/-/chat-ui-kit-styles-1.4.0.tgz", "integrity": "sha512-016mBJD3DESw7Nh+lkKcPd22xG92ghA0VpIXIbjQtmXhC7Ve6wRazTy8z1Ahut+Tbv179+JxrftuMngsj/yV8Q==" }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", + "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz", + "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.0", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", @@ -1847,6 +1901,31 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@tanstack/react-virtual": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.8.tgz", + "integrity": "sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA==", + "dependencies": { + "@tanstack/virtual-core": "3.10.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.8.tgz", + "integrity": "sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.3.0.tgz", @@ -5016,6 +5095,11 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/hamt_plus": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/hamt_plus/-/hamt_plus-1.0.2.tgz", + "integrity": "sha512-t2JXKaehnMb9paaYA7J0BX8QQAY8lwfQ9Gjf4pg/mk4krt+cmwmU652HOoWonf+7+EQV97ARPMhhVgU1ra2GhA==" + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -9266,6 +9350,16 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-frame-component": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/react-frame-component/-/react-frame-component-5.2.7.tgz", + "integrity": "sha512-ROjHtSLoSVYUBfTieazj/nL8jIX9rZFmHC0yXEU+dx6Y82OcBEGgU9o7VyHMrBFUN9FuQ849MtIPNNLsb4krbg==", + "peerDependencies": { + "prop-types": "^15.5.9", + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-infinite-scroller": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/react-infinite-scroller/-/react-infinite-scroller-1.2.6.tgz", @@ -9415,6 +9509,25 @@ "react-dom": ">=16 || >=17 || >= 18" } }, + "node_modules/recoil": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/recoil/-/recoil-0.7.7.tgz", + "integrity": "sha512-8Og5KPQW9LwC577Vc7Ug2P0vQshkv1y3zG3tSSkWMqkWSwHmE+by06L8JtnGocjW6gcCvfwB3YtrJG6/tWivNQ==", + "dependencies": { + "hamt_plus": "1.0.2" + }, + "peerDependencies": { + "react": ">=16.13.1" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", diff --git a/package.json b/package.json index f978862..7335cc8 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,15 @@ }, "dependencies": { "@chatscope/chat-ui-kit-react": "^2.0.3", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.16.4", "@mui/lab": "^5.0.0-alpha.173", "@mui/material": "^5.16.7", "@reduxjs/toolkit": "^2.2.7", + "@tanstack/react-virtual": "^3.10.8", "@testing-library/jest-dom": "^6.4.6", "@testing-library/user-event": "^14.5.2", "@tiptap/extension-color": "^2.5.9", @@ -49,6 +52,7 @@ "react-countdown-circle-timer": "^3.2.1", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", + "react-frame-component": "^5.2.7", "react-infinite-scroller": "^1.2.6", "react-intersection-observer": "^9.13.0", "react-qr-code": "^2.0.15", @@ -56,6 +60,7 @@ "react-redux": "^9.1.2", "react-virtualized": "^9.22.5", "react-virtuoso": "^4.10.4", + "recoil": "^0.7.7", "short-unique-id": "^5.2.0", "slate": "^0.103.0", "slate-react": "^0.109.0", diff --git a/public/appsBg.svg b/public/appsBg.svg new file mode 100644 index 0000000..9775d89 --- /dev/null +++ b/public/appsBg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/content-script.js b/public/content-script.js index e84cd7d..bd5622f 100644 --- a/public/content-script.js +++ b/public/content-script.js @@ -1,290 +1,890 @@ +class Semaphore { + constructor(count) { + this.count = count + this.waiting = [] + } + acquire() { + return new Promise(resolve => { + if (this.count > 0) { + this.count-- + resolve() + } else { + this.waiting.push(resolve) + } + }) + } + release() { + if (this.waiting.length > 0) { + const resolve = this.waiting.shift() + resolve() + } else { + this.count++ + } + } +} +let semaphore = new Semaphore(1) +let reader = new FileReader() - async function connection(hostname) { +const fileToBase64 = (file) => new Promise(async (resolve, reject) => { + if (!reader) { + reader = new FileReader() + } + await semaphore.acquire() + reader.readAsDataURL(file) + reader.onload = () => { + const dataUrl = reader.result + if (typeof dataUrl === "string") { + const base64String = dataUrl.split(',')[1] + reader.onload = null + reader.onerror = null + resolve(base64String) + } else { + reader.onload = null + reader.onerror = null + reject(new Error('Invalid data URL')) + } + semaphore.release() + } + reader.onerror = (error) => { + reader.onload = null + reader.onerror = null + reject(error) + semaphore.release() + } +}) + + + + +async function connection(hostname) { const isConnected = await chrome.storage.local.get([hostname]); - let connected = false - if(isConnected && Object.keys(isConnected).length > 0 && isConnected[hostname]){ - connected = true + let connected = false; + if ( + isConnected && + Object.keys(isConnected).length > 0 && + isConnected[hostname] + ) { + connected = true; } - return connected + return connected; } // In your content script -document.addEventListener('qortalExtensionRequests', async (event) => { +document.addEventListener("qortalExtensionRequests", async (event) => { const { type, payload, requestId, timeout } = event.detail; // Capture the requestId - if (type === 'REQUEST_USER_INFO') { - const hostname = window.location.hostname - const res = await connection(hostname) + if (type === "REQUEST_USER_INFO") { + const hostname = window.location.hostname; + const res = await connection(hostname); - if(!res){ - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "USER_INFO", data: { - error: "Not authorized" - }, requestId } - })); - return + if (!res) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "USER_INFO", + data: { + error: "Not authorized", + }, + requestId, + }, + }) + ); + return; } chrome?.runtime?.sendMessage({ action: "userInfo" }, (response) => { if (response.error) { - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "USER_INFO", data: { - error: response.error - }, requestId } - })); + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "USER_INFO", + data: { + error: response.error, + }, + requestId, + }, + }) + ); } else { // Include the requestId in the detail when dispatching the response - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "USER_INFO", data: response, requestId } - })); + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { type: "USER_INFO", data: response, requestId }, + }) + ); } }); - } else if (type === 'REQUEST_IS_INSTALLED') { + } else if (type === "REQUEST_IS_INSTALLED") { chrome?.runtime?.sendMessage({ action: "version" }, (response) => { if (response.error) { console.error("Error:", response.error); } else { // Include the requestId in the detail when dispatching the response - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "IS_INSTALLED", data: response, requestId } - })); + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { type: "IS_INSTALLED", data: response, requestId }, + }) + ); } }); - } else if (type === 'REQUEST_CONNECTION') { - const hostname = window.location.hostname - chrome?.runtime?.sendMessage({ action: "connection", payload: { - hostname - }, timeout }, (response) => { - if (response.error) { - console.error("Error:", response.error); - } else { - // Include the requestId in the detail when dispatching the response - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "CONNECTION", data: response, requestId } - })); + } else if (type === "REQUEST_CONNECTION") { + const hostname = window.location.hostname; + chrome?.runtime?.sendMessage( + { + action: "connection", + payload: { + hostname, + }, + timeout, + }, + (response) => { + if (response.error) { + console.error("Error:", response.error); + } else { + // Include the requestId in the detail when dispatching the response + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { type: "CONNECTION", data: response, requestId }, + }) + ); + } } - }); - } else if (type === 'REQUEST_OAUTH') { - const hostname = window.location.hostname - const res = await connection(hostname) - if(!res){ - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "OAUTH", data: { - error: "Not authorized" - }, requestId } - })); - return + ); + } else if (type === "REQUEST_OAUTH") { + const hostname = window.location.hostname; + const res = await connection(hostname); + if (!res) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "OAUTH", + data: { + error: "Not authorized", + }, + requestId, + }, + }) + ); + return; } - chrome?.runtime?.sendMessage({ action: "oauth", payload: { - nodeBaseUrl: payload.nodeBaseUrl, - senderAddress: payload.senderAddress, - senderPublicKey: payload.senderPublicKey, timestamp: payload.timestamp - }}, (response) => { - if (response.error) { - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "OAUTH", data: { - error: response.error - }, requestId } - })); - } else { - // Include the requestId in the detail when dispatching the response - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "OAUTH", data: response, requestId } - })); + chrome?.runtime?.sendMessage( + { + action: "oauth", + payload: { + nodeBaseUrl: payload.nodeBaseUrl, + senderAddress: payload.senderAddress, + senderPublicKey: payload.senderPublicKey, + timestamp: payload.timestamp, + }, + }, + (response) => { + if (response.error) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "OAUTH", + data: { + error: response.error, + }, + requestId, + }, + }) + ); + } else { + // Include the requestId in the detail when dispatching the response + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { type: "OAUTH", data: response, requestId }, + }) + ); + } } - }); - } else if (type === 'REQUEST_BUY_ORDER') { - const hostname = window.location.hostname - const res = await connection(hostname) - if(!res){ - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "BUY_ORDER", data: { - error: "Not authorized" - }, requestId } - })); - return + ); + } else if (type === "REQUEST_BUY_ORDER") { + const hostname = window.location.hostname; + const res = await connection(hostname); + if (!res) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "BUY_ORDER", + data: { + error: "Not authorized", + }, + requestId, + }, + }) + ); + return; } - chrome?.runtime?.sendMessage({ action: "buyOrder", payload: { - qortalAtAddresses: payload.qortalAtAddresses, - hostname, - useLocal: payload?.useLocal - - }, timeout}, (response) => { - if (response.error) { - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "BUY_ORDER", data: { - error: response.error - }, requestId } - })); - } else { - // Include the requestId in the detail when dispatching the response - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "BUY_ORDER", data: response, requestId } - })); + chrome?.runtime?.sendMessage( + { + action: "buyOrder", + payload: { + qortalAtAddresses: payload.qortalAtAddresses, + hostname, + useLocal: payload?.useLocal, + }, + timeout, + }, + (response) => { + if (response.error) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "BUY_ORDER", + data: { + error: response.error, + }, + requestId, + }, + }) + ); + } else { + // Include the requestId in the detail when dispatching the response + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { type: "BUY_ORDER", data: response, requestId }, + }) + ); + } } - }); - } else if(type === 'REQUEST_LTC_BALANCE'){ - - - const hostname = window.location.hostname - const res = await connection(hostname) - if(!res){ - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "USER_INFO", data: { - error: "Not authorized" - }, requestId } - })); - return + ); + } else if (type === "REQUEST_LTC_BALANCE") { + const hostname = window.location.hostname; + const res = await connection(hostname); + if (!res) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "USER_INFO", + data: { + error: "Not authorized", + }, + requestId, + }, + }) + ); + return; } - chrome?.runtime?.sendMessage({ action: "ltcBalance", payload: { - hostname - }, timeout }, (response) => { - - if (response.error) { - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "LTC_BALANCE", data: { - error: response.error - }, requestId } - })); - } else { - // Include the requestId in the detail when dispatching the response - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "LTC_BALANCE", data: response, requestId } - })); + chrome?.runtime?.sendMessage( + { + action: "ltcBalance", + payload: { + hostname, + }, + timeout, + }, + (response) => { + if (response.error) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "LTC_BALANCE", + data: { + error: response.error, + }, + requestId, + }, + }) + ); + } else { + // Include the requestId in the detail when dispatching the response + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { type: "LTC_BALANCE", data: response, requestId }, + }) + ); + } } - }); - } else if(type === 'CHECK_IF_LOCAL'){ - - - const hostname = window.location.hostname - const res = await connection(hostname) - if(!res){ - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "USER_INFO", data: { - error: "Not authorized" - }, requestId } - })); - return + ); + } else if (type === "CHECK_IF_LOCAL") { + const hostname = window.location.hostname; + const res = await connection(hostname); + if (!res) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "USER_INFO", + data: { + error: "Not authorized", + }, + requestId, + }, + }) + ); + return; } - chrome?.runtime?.sendMessage({ action: "checkLocal", payload: { - hostname - }, timeout }, (response) => { - - if (response.error) { - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "CHECK_IF_LOCAL", data: { - error: response.error - }, requestId } - })); - } else { - // Include the requestId in the detail when dispatching the response - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "CHECK_IF_LOCAL", data: response, requestId } - })); + chrome?.runtime?.sendMessage( + { + action: "checkLocal", + payload: { + hostname, + }, + timeout, + }, + (response) => { + if (response.error) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "CHECK_IF_LOCAL", + data: { + error: response.error, + }, + requestId, + }, + }) + ); + } else { + // Include the requestId in the detail when dispatching the response + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { type: "CHECK_IF_LOCAL", data: response, requestId }, + }) + ); + } } - }); - } else if (type === 'REQUEST_AUTHENTICATION') { - const hostname = window.location.hostname - const res = await connection(hostname) - if(!res){ - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "USER_INFO", data: { - error: "Not authorized" - }, requestId } - })); - return + ); + } else if (type === "REQUEST_AUTHENTICATION") { + const hostname = window.location.hostname; + const res = await connection(hostname); + if (!res) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "USER_INFO", + data: { + error: "Not authorized", + }, + requestId, + }, + }) + ); + return; } - chrome?.runtime?.sendMessage({ action: "authentication", payload: { - hostname - }, timeout }, (response) => { - if (response.error) { - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "AUTHENTICATION", data: { - error: response.error - }, requestId } - })); - } else { - // Include the requestId in the detail when dispatching the response - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "AUTHENTICATION", data: response, requestId } - })); + chrome?.runtime?.sendMessage( + { + action: "authentication", + payload: { + hostname, + }, + timeout, + }, + (response) => { + if (response.error) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "AUTHENTICATION", + data: { + error: response.error, + }, + requestId, + }, + }) + ); + } else { + // Include the requestId in the detail when dispatching the response + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { type: "AUTHENTICATION", data: response, requestId }, + }) + ); + } } - }); - } else if (type === 'REQUEST_SEND_QORT') { - const hostname = window.location.hostname - const res = await connection(hostname) - if(!res){ - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "USER_INFO", data: { - error: "Not authorized" - }, requestId } - })); - return + ); + } else if (type === "REQUEST_SEND_QORT") { + const hostname = window.location.hostname; + const res = await connection(hostname); + if (!res) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "USER_INFO", + data: { + error: "Not authorized", + }, + requestId, + }, + }) + ); + return; } - chrome?.runtime?.sendMessage({ action: "sendQort", payload: { - hostname, - amount: payload.amount, - description: payload.description, - address: payload.address - }, timeout }, (response) => { - if (response.error) { - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "SEND_QORT", data: { - error: response.error - }, requestId } - })); - } else { - // Include the requestId in the detail when dispatching the response - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "SEND_QORT", data: response, requestId } - })); + chrome?.runtime?.sendMessage( + { + action: "sendQort", + payload: { + hostname, + amount: payload.amount, + description: payload.description, + address: payload.address, + }, + timeout, + }, + (response) => { + if (response.error) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "SEND_QORT", + data: { + error: response.error, + }, + requestId, + }, + }) + ); + } else { + // Include the requestId in the detail when dispatching the response + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { type: "SEND_QORT", data: response, requestId }, + }) + ); + } } - }); - } else if (type === 'REQUEST_CLOSE_POPUP') { - const hostname = window.location.hostname - const res = await connection(hostname) - if(!res){ - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "USER_INFO", data: { - error: "Not authorized" - }, requestId } - })); - return + ); + } else if (type === "REQUEST_CLOSE_POPUP") { + const hostname = window.location.hostname; + const res = await connection(hostname); + if (!res) { + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "USER_INFO", + data: { + error: "Not authorized", + }, + requestId, + }, + }) + ); + return; } chrome?.runtime?.sendMessage({ action: "closePopup" }, (response) => { if (response.error) { - - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "CLOSE_POPUP", data: { - error: response.error - }, requestId } - })); + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { + type: "CLOSE_POPUP", + data: { + error: response.error, + }, + requestId, + }, + }) + ); } else { // Include the requestId in the detail when dispatching the response - document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { - detail: { type: "CLOSE_POPUP", data: true, requestId } - })); + document.dispatchEvent( + new CustomEvent("qortalExtensionResponses", { + detail: { type: "CLOSE_POPUP", data: true, requestId }, + }) + ); } }); } // Handle other request types as needed... }); +async function handleGetFileFromIndexedDB(fileId, sendResponse) { + try { + const db = await openIndexedDB(); + const transaction = db.transaction(["files"], "readonly"); + const objectStore = transaction.objectStore("files"); -chrome.runtime?.onMessage.addListener(function(message, sender, sendResponse) { + const getRequest = objectStore.get(fileId); + + getRequest.onsuccess = async function (event) { + if (getRequest.result) { + const file = getRequest.result.data; + + try { + const base64String = await fileToBase64(file); + + // Create a new transaction to delete the file + const deleteTransaction = db.transaction(["files"], "readwrite"); + const deleteObjectStore = deleteTransaction.objectStore("files"); + const deleteRequest = deleteObjectStore.delete(fileId); + + deleteRequest.onsuccess = function () { + console.log(`File with ID ${fileId} has been removed from IndexedDB`); + try { + sendResponse({ result: base64String }); + + } catch (error) { + console.log('error', error) + } + }; + + deleteRequest.onerror = function () { + console.error(`Error deleting file with ID ${fileId} from IndexedDB`); + sendResponse({ result: null, error: "Failed to delete file from IndexedDB" }); + }; + } catch (error) { + console.error("Error converting file to Base64:", error); + sendResponse({ result: null, error: "Failed to convert file to Base64" }); + } + } else { + console.error(`File with ID ${fileId} not found in IndexedDB`); + sendResponse({ result: null, error: "File not found in IndexedDB" }); + } + }; + + getRequest.onerror = function () { + console.error(`Error retrieving file with ID ${fileId} from IndexedDB`); + sendResponse({ result: null, error: "Error retrieving file from IndexedDB" }); + }; + } catch (error) { + console.error("Error opening IndexedDB:", error); + sendResponse({ result: null, error: "Error opening IndexedDB" }); + } +} + +const testAsync = async (sendResponse)=> { + await new Promise((res)=> { + setTimeout(() => { + res() + }, 2500); + }) + sendResponse({ result: null, error: "Testing" }); +} + +const saveFile = (blob, filename) => { + // Create a URL for the blob + const url = URL.createObjectURL(blob); + + // Create a link element + const a = document.createElement('a'); + a.href = url; + a.download = filename; + + // Append the link to the document and trigger a click + document.body.appendChild(a); + a.click(); + + // Clean up by removing the link and revoking the object URL + document.body.removeChild(a); + URL.revokeObjectURL(url); +}; + + + + +const showSaveFilePicker = async (data) => { + let blob + let fileName + try { + const {filename, mimeType, fileHandleOptions, fileId} = data + blob = await retrieveFileFromIndexedDB(fileId) + fileName = filename + const fileHandle = await window.showSaveFilePicker({ + suggestedName: filename, + types: [ + { + description: mimeType, + ...fileHandleOptions + } + ] + }) + const writeFile = async (fileHandle, contents) => { + const writable = await fileHandle.createWritable() + await writable.write(contents) + await writable.close() + } + writeFile(fileHandle, blob).then(() => console.log("FILE SAVED")) +} catch (error) { + saveFile(blob, fileName) +} +} + +chrome.runtime?.onMessage.addListener( function (message, sender, sendResponse) { if (message.type === "LOGOUT") { - // Notify the web page - window.postMessage({ - type: "LOGOUT", - from: 'qortal' - }, "*"); + // Notify the web page + window.postMessage( + { + type: "LOGOUT", + from: "qortal", + }, + "*" + ); } else if (message.type === "RESPONSE_FOR_TRADES") { // Notify the web page - window.postMessage({ + window.postMessage( + { type: "RESPONSE_FOR_TRADES", - from: 'qortal', - payload: message.message - }, "*"); -} + from: "qortal", + payload: message.message, + }, + "*" + ); + } else if(message.action === "SHOW_SAVE_FILE_PICKER"){ + showSaveFilePicker(message?.data) + } + + else if (message.action === "getFileFromIndexedDB") { + handleGetFileFromIndexedDB(message.fileId, sendResponse); + return true; // Keep the message channel open for async response + } }); +function openIndexedDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open("fileStorageDB", 1); + + request.onupgradeneeded = function (event) { + const db = event.target.result; + if (!db.objectStoreNames.contains("files")) { + db.createObjectStore("files", { keyPath: "id" }); + } + }; + + request.onsuccess = function (event) { + resolve(event.target.result); + }; + + request.onerror = function () { + reject("Error opening IndexedDB"); + }; + }); +} + + +async function retrieveFileFromIndexedDB(fileId) { + const db = await openIndexedDB(); + const transaction = db.transaction(["files"], "readwrite"); + const objectStore = transaction.objectStore("files"); + + return new Promise((resolve, reject) => { + const getRequest = objectStore.get(fileId); + + getRequest.onsuccess = function (event) { + if (getRequest.result) { + // File found, resolve it and delete from IndexedDB + const file = getRequest.result.data; + objectStore.delete(fileId); + resolve(file); + } else { + reject("File not found in IndexedDB"); + } + }; + + getRequest.onerror = function () { + reject("Error retrieving file from IndexedDB"); + }; + }); +} + +async function deleteQortalFilesFromIndexedDB() { + try { + console.log("Opening IndexedDB for deleting files..."); + const db = await openIndexedDB(); + const transaction = db.transaction(["files"], "readwrite"); + const objectStore = transaction.objectStore("files"); + + // Create a request to get all keys + const getAllKeysRequest = objectStore.getAllKeys(); + + getAllKeysRequest.onsuccess = function (event) { + const keys = event.target.result; + + // Iterate through keys to find and delete those containing '_qortalfile' + for (let key of keys) { + if (key.includes("_qortalfile")) { + const deleteRequest = objectStore.delete(key); + + deleteRequest.onsuccess = function () { + console.log(`File with key '${key}' has been deleted from IndexedDB`); + }; + + deleteRequest.onerror = function () { + console.error(`Failed to delete file with key '${key}' from IndexedDB`); + }; + } + } + }; + + getAllKeysRequest.onerror = function () { + console.error("Failed to retrieve keys from IndexedDB"); + }; + + transaction.oncomplete = function () { + console.log("Transaction complete for deleting files from IndexedDB"); + }; + + transaction.onerror = function () { + console.error("Error occurred during transaction for deleting files"); + }; + } catch (error) { + console.error("Error opening IndexedDB:", error); + } +} + + +async function storeFilesInIndexedDB(obj) { + // First delete any existing files in IndexedDB with '_qortalfile' in their ID + await deleteQortalFilesFromIndexedDB(); + + // Open the IndexedDB + const db = await openIndexedDB(); + const transaction = db.transaction(["files"], "readwrite"); + const objectStore = transaction.objectStore("files"); + + // Handle the obj.file if it exists and is a File instance + if (obj.file instanceof File) { + const fileId = "objFile_qortalfile"; + + // Store the file in IndexedDB + const fileData = { + id: fileId, + data: obj.file, + }; + objectStore.put(fileData); + + // Replace the file object with the file ID in the original object + obj.fileId = fileId; + delete obj.file; + } + if (obj.blob instanceof Blob) { + const fileId = "objFile_qortalfile"; + + // Store the file in IndexedDB + const fileData = { + id: fileId, + data: obj.blob, + }; + objectStore.put(fileData); + + // Replace the file object with the file ID in the original object + let blobObj = { + type: obj.blob?.type + } + obj.fileId = fileId; + delete obj.blob; + obj.blob = blobObj + } + + // Iterate through resources to find files and save them to IndexedDB + for (let resource of (obj?.resources || [])) { + if (resource.file instanceof File) { + const fileId = resource.identifier + "_qortalfile"; + + // Store the file in IndexedDB + const fileData = { + id: fileId, + data: resource.file, + }; + objectStore.put(fileData); + + // Replace the file object with the file ID in the original object + resource.fileId = fileId; + delete resource.file; + } + } + + // Set transaction completion handlers + transaction.oncomplete = function () { + console.log("Files saved successfully to IndexedDB"); + }; + + transaction.onerror = function () { + console.error("Error saving files to IndexedDB"); + }; + + return obj; // Updated object with references to stored files +} + + + +const UIQortalRequests = ['GET_USER_ACCOUNT', 'DECRYPT_DATA', 'SEND_COIN', 'GET_LIST_ITEMS', 'ADD_LIST_ITEMS', 'DELETE_LIST_ITEM', 'VOTE_ON_POLL', 'CREATE_POLL', 'SEND_CHAT_MESSAGE', 'JOIN_GROUP', 'DEPLOY_AT', 'GET_USER_WALLET', 'GET_WALLET_BALANCE', 'GET_USER_WALLET_INFO', 'GET_CROSSCHAIN_SERVER_INFO', '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'] + +if (!window.hasAddedQortalListener) { + console.log("Listener added"); + window.hasAddedQortalListener = true; + //qortalRequests + const listener = async (event) => { + event.preventDefault(); // Prevent default behavior + event.stopImmediatePropagation(); // Stop other listeners from firing + + // Verify that the message is from the web page and contains expected data + if (event.source !== window || !event.data || !event.data.action) return; + + if (event?.data?.requestedHandler !== 'UI') return; + + await new Promise((res)=> { + chrome?.runtime?.sendMessage( + { + action: "authentication", + timeout: 60, + }, + (response) => { + if (response.error) { + eventPort.postMessage({ + result: null, + error: 'User not authenticated', + }); + res() + return + } else { + res() + } + } + ); + }) + + const sendMessageToRuntime = (message, eventPort) => { + chrome?.runtime?.sendMessage(message, (response) => { + if (response.error) { + eventPort.postMessage({ + result: null, + error: response, + }); + } else { + eventPort.postMessage({ + result: response, + error: null, + }); + } + }); + }; + + // Check if action is included in the predefined list of UI requests + if (UIQortalRequests.includes(event.data.action)) { + sendMessageToRuntime( + { action: event.data.action, type: 'qortalRequest', payload: event.data }, + event.ports[0] + ); + } 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') { + let data; + try { + data = await storeFilesInIndexedDB(event.data); + } catch (error) { + console.error('Error storing files in IndexedDB:', error); + event.ports[0].postMessage({ + result: null, + error: 'Failed to store files in IndexedDB', + }); + return; + } + + if (data) { + sendMessageToRuntime( + { action: event.data.action, type: 'qortalRequest', payload: data }, + event.ports[0] + ); + } else { + event.ports[0].postMessage({ + result: null, + error: 'Failed to prepare data for publishing', + }); + } + } + }; + + // Add the listener for messages coming from the window + window.addEventListener('message', listener); + +} + + diff --git a/public/disable-gateway-message.js b/public/disable-gateway-message.js new file mode 100644 index 0000000..c395aff --- /dev/null +++ b/public/disable-gateway-message.js @@ -0,0 +1,26 @@ +(function() { + // Immediately disable qdnGatewayShowModal if it exists + + + // Now, let's wrap the handleResponse function with the new condition + const originalHandleResponse = window.handleResponse; // Save the original handleResponse function + + if (typeof originalHandleResponse === 'function') { + // Create the wrapper function to enhance the original handleResponse + window.handleResponse = function(event, response) { + // Check if the response contains the specific error message + if (response && typeof response === 'string' && response.includes("Interactive features were requested")) { + console.log('Response contains "Interactive features were requested", skipping processing.'); + return; // Skip further processing + } + + // Call the original handleResponse for normal processing + originalHandleResponse(event, response); + }; + + console.log('handleResponse has been enhanced to skip specific error handling.'); + } else { + console.log('No handleResponse function found to enhance.'); + } + +})(); diff --git a/public/disable-gateway-popup.js b/public/disable-gateway-popup.js new file mode 100644 index 0000000..7cf89e4 --- /dev/null +++ b/public/disable-gateway-popup.js @@ -0,0 +1,27 @@ +(function() { + console.log('External script loaded to disable qdnGatewayShowModal'); + + const timeoutDuration = 5000; // Set timeout duration to 5 seconds (5000ms) + let elapsedTime = 0; // Track the time that has passed + + // Poll for qdnGatewayShowModal and disable it once it's defined + const checkQdnGatewayInterval = setInterval(() => { + elapsedTime += 100; // Increment elapsed time by the polling interval (100ms) + + if (typeof window.qdnGatewayShowModal === 'function') { + console.log('Disabling qdnGatewayShowModal'); + + // Disable qdnGatewayShowModal function + window.qdnGatewayShowModal = function(message) { + console.log('qdnGatewayShowModal function has been disabled.'); + }; + + // Stop checking once qdnGatewayShowModal has been disabled + clearInterval(checkQdnGatewayInterval); + } else if (elapsedTime >= timeoutDuration) { + console.log('Timeout reached, stopping polling for qdnGatewayShowModal.'); + clearInterval(checkQdnGatewayInterval); // Stop checking after 5 seconds + } + }, 100); // Check every 100ms + +})(); diff --git a/public/document_end.js b/public/document_end.js new file mode 100644 index 0000000..748d513 --- /dev/null +++ b/public/document_end.js @@ -0,0 +1,7 @@ + +const script2 = document.createElement('script'); +script2.src = chrome.runtime.getURL('disable-gateway-message.js'); // Reference the external script +document.documentElement.appendChild(script2); // Inject it into the page +script2.onload = function() { + script2.remove(); // Clean up after the script has been injected and run +}; \ No newline at end of file diff --git a/public/document_start.js b/public/document_start.js new file mode 100644 index 0000000..19c7adf --- /dev/null +++ b/public/document_start.js @@ -0,0 +1,7 @@ + +const script = document.createElement('script'); +script.src = chrome.runtime.getURL('disable-gateway-popup.js'); // Reference the external script +document.documentElement.appendChild(script); // Inject it into the page +script.onload = function() { + script.remove(); // Clean up after the script has been injected and run +}; \ No newline at end of file diff --git a/public/manifest.json b/public/manifest.json index 043c074..73c4626 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Qortal", - "version": "2.1.1", + "version": "2.2.0", "icons": { "16": "qort.png", "32": "qort.png", @@ -18,12 +18,34 @@ ], "content_scripts": [ + { + "matches": [""], + "js": ["document_start.js"], + "run_at": "document_start" + }, { "matches": [""], "js": ["content-script.js"] + }, + + { + "matches": [""], + "js": ["document_end.js"], + "run_at": "document_end" } ], + "web_accessible_resources": [ + { + "resources": ["disable-gateway-popup.js"], + "matches": [""] + }, + { + "resources": ["disable-gateway-message.js"], + "matches": [""] + } + + ], "content_security_policy": { - "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' https://api.qortal.org https://api2.qortal.org https://appnode.qortal.org https://apinode.qortalnodes.live https://apinode1.qortalnodes.live https://apinode2.qortalnodes.live https://apinode3.qortalnodes.live https://apinode4.qortalnodes.live https://ext-node.qortal.link wss://appnode.qortal.org wss://ext-node.qortal.link ws://127.0.0.1:12391 http://127.0.0.1:12391 https://ext-node.qortal.link; " + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' https://*:* http://*:* wss://*:* ws://*:*;" } } diff --git a/src/App.tsx b/src/App.tsx index ac78985..5c5445e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,7 +42,7 @@ import Logout from "./assets/svgs/Logout.svg"; import Return from "./assets/svgs/Return.svg"; import Success from "./assets/svgs/Success.svg"; import Info from "./assets/svgs/Info.svg"; -import CloseIcon from '@mui/icons-material/Close'; +import CloseIcon from "@mui/icons-material/Close"; import { createAccount, @@ -70,27 +70,42 @@ import { Spacer } from "./common/Spacer"; import { Loader } from "./components/Loader"; import { PasswordField, ErrorText } from "./components"; import { ChatGroup } from "./components/Chat/ChatGroup"; -import { Group, requestQueueMemberNames } from "./components/Group/Group"; +import { Group, requestQueueMemberNames } from "./components/Group/Group"; import { TaskManger } from "./components/TaskManager/TaskManger"; import { useModal } from "./common/useModal"; import { LoadingButton } from "@mui/lab"; import { Label } from "./components/Group/AddGroup"; import { CustomizedSnackbars } from "./components/Snackbar/Snackbar"; -import SettingsIcon from '@mui/icons-material/Settings'; +import SettingsIcon from "@mui/icons-material/Settings"; import { + cleanUrl, getFee, + getProtocol, groupApi, groupApiLocal, groupApiSocket, groupApiSocketLocal, } from "./background"; -import { executeEvent, subscribeToEvent, unsubscribeFromEvent } from "./utils/events"; -import { requestQueueCommentCount, requestQueuePublishedAccouncements } from "./components/Chat/GroupAnnouncements"; +import { + executeEvent, + subscribeToEvent, + unsubscribeFromEvent, +} from "./utils/events"; +import { + requestQueueCommentCount, + requestQueuePublishedAccouncements, +} from "./components/Chat/GroupAnnouncements"; import { requestQueueGroupJoinRequests } from "./components/Group/GroupJoinRequests"; import { DrawerComponent } from "./components/Drawer/Drawer"; import { AddressQRCode } from "./components/AddressQRCode"; import { Settings } from "./components/Group/Settings"; 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 { useAppFullScreen } from "./useAppFullscreen"; +import { NotAuthenticated } from "./ExtStates/NotAuthenticated"; type extStates = | "not-authenticated" @@ -134,11 +149,11 @@ const defaultValues: MyContextInterface = { message: "", }, }; -export let isMobile = false +export let isMobile = false; const isMobileDevice = () => { const userAgent = navigator.userAgent || navigator.vendor || window.opera; - + if (/android/i.test(userAgent)) { return true; // Android device } @@ -151,7 +166,7 @@ const isMobileDevice = () => { }; if (isMobileDevice()) { - isMobile = true + isMobile = true; console.log("Running on a mobile device"); } else { console.log("Running on a desktop"); @@ -161,14 +176,14 @@ export const allQueues = { requestQueueCommentCount: requestQueueCommentCount, requestQueuePublishedAccouncements: requestQueuePublishedAccouncements, requestQueueMemberNames: requestQueueMemberNames, - requestQueueGroupJoinRequests: requestQueueGroupJoinRequests -} + requestQueueGroupJoinRequests: requestQueueGroupJoinRequests, +}; const controlAllQueues = (action) => { Object.keys(allQueues).forEach((key) => { const val = allQueues[key]; try { - if (typeof val[action] === 'function') { + if (typeof val[action] === "function") { val[action](); } } catch (error) { @@ -186,50 +201,39 @@ export const clearAllQueues = () => { console.error(error); } }); -} +}; export const pauseAllQueues = () => { - controlAllQueues('pause'); - chrome?.runtime?.sendMessage( - { - action: "pauseAllQueues", - payload: { - - }, - } - ); -} + controlAllQueues("pause"); + chrome?.runtime?.sendMessage({ + action: "pauseAllQueues", + payload: {}, + }); +}; export const resumeAllQueues = () => { - controlAllQueues('resume'); - chrome?.runtime?.sendMessage( - { - action: "resumeAllQueues", - payload: { - - }, - } - ); -} - + controlAllQueues("resume"); + chrome?.runtime?.sendMessage({ + action: "resumeAllQueues", + payload: {}, + }); +}; export const MyContext = createContext(defaultValues); export let globalApiKey: string | null = null; export const getBaseApiReact = (customApi?: string) => { - if (customApi) { return customApi; } if (globalApiKey) { - return groupApiLocal; + return globalApiKey?.url; } else { return groupApi; } }; // export const getArbitraryEndpointReact = () => { - // if (globalApiKey) { // return `/arbitrary/resources/search`; @@ -238,22 +242,19 @@ export const getBaseApiReact = (customApi?: string) => { // } // }; export const getArbitraryEndpointReact = () => { - - if (globalApiKey) { - return `/arbitrary/resources/search`; + return `/arbitrary/resources/searchsimple`; } else { return `/arbitrary/resources/searchsimple`; } }; export const getBaseApiReactSocket = (customApi?: string) => { - if (customApi) { return customApi; } if (globalApiKey) { - return groupApiSocketLocal; + return `${getProtocol(globalApiKey?.url) === 'http' ? 'ws://': 'wss://'}${cleanUrl(globalApiKey?.url)}` } else { return groupApiSocket; } @@ -261,6 +262,8 @@ export const getBaseApiReactSocket = (customApi?: string) => { export const isMainWindow = window?.location?.href?.includes("?main=true"); function App() { const [extState, setExtstate] = useState("not-authenticated"); + const [desktopViewMode, setDesktopViewMode] = useState('home') + const [backupjson, setBackupjson] = useState(null); const [rawWallet, setRawWallet] = useState(null); const [ltcBalanceLoading, setLtcBalanceLoading] = useState(false); @@ -300,10 +303,27 @@ function App() { const [txList, setTxList] = useState([]); const [memberGroups, setMemberGroups] = useState([]); const [isFocused, setIsFocused] = useState(true); - + const [hasSettingsChanged, setHasSettingsChanged] = useRecoilState(hasSettingsChangedAtom) const holdRefExtState = useRef("not-authenticated"); const isFocusedRef = useRef(true); const { isShow, onCancel, onOk, show, message } = useModal(); + const { isShow: isShowUnsavedChanges, onCancel: onCancelUnsavedChanges, onOk: onOkUnsavedChanges, show: showUnsavedChanges, message: messageUnsavedChanges } = useModal(); + + const { + onCancel: onCancelQortalRequest, + onOk: onOkQortalRequest, + show: showQortalRequest, + isShow: isShowQortalRequest, + message: messageQortalRequest, + } = useModal(); + const { + onCancel: onCancelQortalRequestExtension, + onOk: onOkQortalRequestExtension, + show: showQortalRequestExtension, + isShow: isShowQortalRequestExtension, + message: messageQortalRequestExtension, + } = useModal(); + const [openRegisterName, setOpenRegisterName] = useState(false); const registerNamePopoverRef = useRef(null); const [isLoadingRegisterName, setIsLoadingRegisterName] = useState(false); @@ -311,47 +331,81 @@ function App() { const [infoSnack, setInfoSnack] = useState(null); const [openSnack, setOpenSnack] = useState(false); const [hasLocalNode, setHasLocalNode] = useState(false); - const [openAdvancedSettings, setOpenAdvancedSettings] = useState(false); - const [useLocalNode, setUseLocalNode] = useState(false); - const [confirmUseOfLocal, setConfirmUseOfLocal] = useState(false); const [isOpenDrawerProfile, setIsOpenDrawerProfile] = useState(false); const [apiKey, setApiKey] = useState(""); - const [isOpenSendQort, setIsOpenSendQort] = useState(false) - const [isOpenSendQortSuccess, setIsOpenSendQortSuccess] = useState(false) - const [rootHeight, setRootHeight] = useState('100%') - const [isSettingsOpen, setIsSettingsOpen] = useState(false) + const [isOpenSendQort, setIsOpenSendQort] = useState(false); + const [isOpenSendQortSuccess, setIsOpenSendQortSuccess] = useState(false); + const [rootHeight, setRootHeight] = useState("100%"); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const qortalRequestCheckbox1Ref = useRef(null); + useRetrieveDataLocalStorage() + useQortalGetSaveSettings(userInfo?.name) + const [fullScreen, setFullScreen] = useRecoilState(fullScreenAtom); + + const { toggleFullScreen } = useAppFullScreen(setFullScreen); + useEffect(() => { - if(!isMobile) return + // Attach a global event listener for double-click + const handleDoubleClick = () => { + toggleFullScreen(); + }; + + // Add the event listener to the root HTML document + document.documentElement.addEventListener('dblclick', handleDoubleClick); + + // Clean up the event listener on unmount + return () => { + document.documentElement.removeEventListener('dblclick', handleDoubleClick); + }; + }, [toggleFullScreen]); + //resets for recoil + const resetAtomSortablePinnedAppsAtom = useResetRecoilState(sortablePinnedAppsAtom); + const resetAtomCanSaveSettingToQdnAtom = useResetRecoilState(canSaveSettingToQdnAtom); + const resetAtomSettingsQDNLastUpdatedAtom = useResetRecoilState(settingsQDNLastUpdatedAtom); + const resetAtomSettingsLocalLastUpdatedAtom = useResetRecoilState(settingsLocalLastUpdatedAtom); + const resetAtomOldPinnedAppsAtom = useResetRecoilState(oldPinnedAppsAtom); + + const resetAllRecoil = () => { + resetAtomSortablePinnedAppsAtom(); + resetAtomCanSaveSettingToQdnAtom(); + resetAtomSettingsQDNLastUpdatedAtom(); + resetAtomSettingsLocalLastUpdatedAtom(); + resetAtomOldPinnedAppsAtom(); + }; + useEffect(() => { + if (!isMobile) return; // Function to set the height of the app to the viewport height const resetHeight = () => { - const height = window.visualViewport ? window.visualViewport.height : window.innerHeight; + const height = window.visualViewport + ? window.visualViewport.height + : window.innerHeight; // Set the height to the root element (usually #root) - document.getElementById('root').style.height = height + "px"; - setRootHeight(height + "px") + document.getElementById("root").style.height = height + "px"; + setRootHeight(height + "px"); }; // Set the initial height resetHeight(); // Add event listeners for resize and visualViewport changes - window.addEventListener('resize', resetHeight); - window.visualViewport?.addEventListener('resize', resetHeight); + window.addEventListener("resize", resetHeight); + window.visualViewport?.addEventListener("resize", resetHeight); // Clean up the event listeners when the component unmounts return () => { - window.removeEventListener('resize', resetHeight); - window.visualViewport?.removeEventListener('resize', resetHeight); + window.removeEventListener("resize", resetHeight); + window.visualViewport?.removeEventListener("resize", resetHeight); }; }, []); + const handleSetGlobalApikey = (key)=> { + globalApiKey = key; + } useEffect(() => { chrome?.runtime?.sendMessage({ action: "getApiKey" }, (response) => { if (response) { - - globalApiKey = response; + handleSetGlobalApikey(response) setApiKey(response); - setUseLocalNode(true) - setConfirmUseOfLocal(true) - setOpenAdvancedSettings(true) + } }); }, []); @@ -365,19 +419,8 @@ function App() { isFocusedRef.current = isFocused; }, [isFocused]); - // Handler for file selection - const handleFileChangeApiKey = (event) => { - const file = event.target.files[0]; // Get the selected file - if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - const text = e.target.result; // Get the file content - setApiKey(text); // Store the file content in the state - }; - reader.readAsText(file); // Read the file as text - } - }; + // const checkIfUserHasLocalNode = useCallback(async () => { // try { // const url = `http://127.0.0.1:12391/admin/status`; @@ -546,8 +589,8 @@ function App() { if (response?.error) { setSendPaymentError(response.error); } else { - setIsOpenSendQort(false) - setIsOpenSendQortSuccess(true) + setIsOpenSendQort(false); + setIsOpenSendQortSuccess(true); // setExtstate("transfer-success-regular"); // setSendPaymentSuccess("Payment successfully sent"); } @@ -561,51 +604,86 @@ function App() { setRequestAuthentication(null); }; + const qortalRequestPermisson = async (message, sender, sendResponse) => { + if (message.action === "QORTAL_REQUEST_PERMISSION" && !isMainWindow) { + try { + + await showQortalRequest(message?.payload); + if (qortalRequestCheckbox1Ref.current) { + + sendResponse({ + accepted: true, + checkbox1: qortalRequestCheckbox1Ref.current, + }); + return; + } + sendResponse({ accepted: true }); + } catch (error) { + sendResponse({ accepted: false }); + } finally { + window.close(); + } + } + }; + const qortalRequestPermissonFromExtension = async (message, sender, sendResponse) => { + if (message.action === "QORTAL_REQUEST_PERMISSION" && isMainWindow) { + try { + + await showQortalRequestExtension(message?.payload); + + if (qortalRequestCheckbox1Ref.current) { + + sendResponse({ + accepted: true, + checkbox1: qortalRequestCheckbox1Ref.current, + }); + return; + } + sendResponse({ accepted: true }); + } catch (error) { + sendResponse({ accepted: false }); + } + } + }; + useEffect(() => { // Listen for messages from the background script - chrome.runtime?.onMessage.addListener((message, sender, sendResponse) => { - // Check if the message is to update the state + const messageListener = (message, sender, sendResponse) => { + // Handle various actions if ( message.action === "UPDATE_STATE_CONFIRM_SEND_QORT" && !isMainWindow ) { - // Update the component state with the received 'sendqort' state setSendqortState(message.payload); setExtstate("web-app-request-payment"); } else if (message.action === "closePopup" && !isMainWindow) { - // Update the component state with the received 'sendqort' state window.close(); } else if ( message.action === "UPDATE_STATE_REQUEST_CONNECTION" && !isMainWindow ) { - - // Update the component state with the received 'sendqort' state setRequestConnection(message.payload); setExtstate("web-app-request-connection"); } else if ( message.action === "UPDATE_STATE_REQUEST_BUY_ORDER" && !isMainWindow ) { - // Update the component state with the received 'sendqort' state setRequestBuyOrder(message.payload); setExtstate("web-app-request-buy-order"); } else if ( message.action === "UPDATE_STATE_REQUEST_AUTHENTICATION" && !isMainWindow ) { - // Update the component state with the received 'sendqort' state setRequestAuthentication(message.payload); setExtstate("web-app-request-authentication"); } else if (message.action === "SET_COUNTDOWN" && !isMainWindow) { setCountdown(message.payload); } else if (message.action === "INITIATE_MAIN") { - // Update the component state with the received 'sendqort' state setIsMain(true); isMainRef.current = true; } else if (message.action === "CHECK_FOCUS" && isMainWindow) { - - sendResponse(isFocusedRef.current); + sendResponse(isFocusedRef.current); // Synchronous response + return true; // Return true if you plan to send a response asynchronously } else if ( message.action === "NOTIFICATION_OPEN_DIRECT" && isMainWindow @@ -632,9 +710,30 @@ function App() { data: message.payload.data, }); } - }); - }, []); + // Call the permission request handler for "QORTAL_REQUEST_PERMISSION" + qortalRequestPermisson(message, sender, sendResponse); + if (message.action === "QORTAL_REQUEST_PERMISSION" && !isMainWindow) { + return true; // Return true to indicate an async response is coming + } + if (message.action === "QORTAL_REQUEST_PERMISSION" && isMainWindow && message?.isFromExtension) { + qortalRequestPermissonFromExtension(message, sender, sendResponse); + return true; + } + if (message.action === "QORTAL_REQUEST_PERMISSION" && isMainWindow) { + + return; + } + }; + + // Add message listener + chrome.runtime?.onMessage.addListener(messageListener); + + // Clean up the listener on component unmount + return () => { + chrome.runtime?.onMessage.removeListener(messageListener); + }; + }, []); //param = isDecline const confirmPayment = (isDecline: boolean) => { @@ -696,7 +795,7 @@ function App() { crosschainAtInfo: requestBuyOrder?.crosschainAtInfo, interactionId: requestBuyOrder?.interactionId, isDecline: true, - useLocal: requestBuyOrder?.useLocal + useLocal: requestBuyOrder?.useLocal, }, }, (response) => { @@ -714,7 +813,7 @@ function App() { crosschainAtInfo: requestBuyOrder?.crosschainAtInfo, interactionId: requestBuyOrder?.interactionId, isDecline: false, - useLocal: requestBuyOrder?.useLocal + useLocal: requestBuyOrder?.useLocal, }, }, (response) => { @@ -767,7 +866,6 @@ function App() { ) return; - setExtstate("authenticated"); } }); @@ -892,12 +990,15 @@ function App() { wallet, qortAddress: wallet.address0, }); - chrome?.runtime?.sendMessage({ action: "userInfo" }, (response2) => { - setIsLoading(false); - if (response2 && !response2.error) { - setUserInfo(response); + chrome?.runtime?.sendMessage( + { action: "userInfo" }, + (response2) => { + setIsLoading(false); + if (response2 && !response2.error) { + setUserInfo(response); + } } - }); + ); getBalanceFunc(); } else if (response?.error) { setIsLoading(false); @@ -911,8 +1012,11 @@ function App() { } }; - const logoutFunc = () => { + const logoutFunc = async () => { try { + if(hasSettingsChanged){ + await showUnsavedChanges({message: 'Your settings have changed. If you logout you will lose your changes. Click on the save button in the header to keep your changed settings.'}) + } chrome?.runtime?.sendMessage({ action: "logout" }, (response) => { if (response) { resetAllStates(); @@ -932,8 +1036,8 @@ function App() { setWalletToBeDownloaded(null); setWalletToBeDownloadedPassword(""); setExtstate("authenticated"); - setIsOpenSendQort(false) - setIsOpenSendQortSuccess(false) + setIsOpenSendQort(false); + setIsOpenSendQortSuccess(false); }; const resetAllStates = () => { @@ -959,14 +1063,10 @@ function App() { setWalletToBeDownloadedPasswordConfirm(""); setWalletToBeDownloadedError(""); setSendqortState(null); - globalApiKey = null; - setApiKey(""); - setUseLocalNode(false); setHasLocalNode(false); - setOpenAdvancedSettings(false); - setConfirmUseOfLocal(false) - setTxList([]) - setMemberGroups([]) + setTxList([]); + setMemberGroups([]); + resetAllRecoil() }; function roundUpToDecimals(number, decimals = 8) { @@ -1048,7 +1148,7 @@ function App() { const handleBeforeUnload = (e) => { e.preventDefault(); e.returnValue = ""; // This is required for Chrome to display the confirmation dialog. - return ""; + return ""; }; // Add the event listener when the component mounts @@ -1065,24 +1165,18 @@ function App() { // Handler for when the window gains focus const handleFocus = () => { setIsFocused(true); - if(isMobile){ - chrome?.runtime?.sendMessage( - { - action: "clearAllNotifications", - payload: { - - }, - } - ); + if (isMobile) { + chrome?.runtime?.sendMessage({ + action: "clearAllNotifications", + payload: {}, + }); } - - console.log("Webview is focused"); + }; // Handler for when the window loses focus const handleBlur = () => { setIsFocused(false); - console.log("Webview is not focused"); }; // Attach the event listeners @@ -1093,20 +1187,14 @@ function App() { const handleVisibilityChange = () => { if (document.visibilityState === "visible") { setIsFocused(true); - if(isMobile){ - chrome?.runtime?.sendMessage( - { - action: "clearAllNotifications", - payload: { - - }, - } - ); + if (isMobile) { + chrome?.runtime?.sendMessage({ + action: "clearAllNotifications", + payload: {}, + }); } - console.log("Webview is visible"); } else { setIsFocused(false); - console.log("Webview is hidden"); } }; @@ -1120,12 +1208,11 @@ function App() { }; }, []); - const openPaymentInternal = (e) => { const directAddress = e.detail?.address; - const name = e.detail?.name - setIsOpenSendQort(true) - setPaymentTo(name || directAddress) + const name = e.detail?.name; + setIsOpenSendQort(true); + setPaymentTo(name || directAddress); }; useEffect(() => { @@ -1154,7 +1241,6 @@ function App() { }, }, (response) => { - if (!response?.error) { res(response); setIsLoadingRegisterName(false); @@ -1199,437 +1285,280 @@ function App() { } }; - const renderProfile = ()=> { + const renderProfile = () => { return ( - - {isMobile && ( - { - setIsOpenDrawerProfile(false) - }} sx={{ - cursor: 'pointer', - color: 'white' - }} /> - )} - - - - - {authenticatedMode === "ltc" ? ( - <> - - - - - {rawWallet?.ltcAddress?.slice(0, 6)}... - {rawWallet?.ltcAddress?.slice(-4)} - - - - {ltcBalanceLoading && ( - - )} - {!isNaN(+ltcBalance) && !ltcBalanceLoading && ( - + {isMobile && ( + + { + setIsOpenDrawerProfile(false); }} - > + sx={{ + cursor: "pointer", + color: "white", + }} + /> + + )} + + + + + {authenticatedMode === "ltc" ? ( + <> + + + + + {rawWallet?.ltcAddress?.slice(0, 6)}... + {rawWallet?.ltcAddress?.slice(-4)} + + + + {ltcBalanceLoading && ( + + )} + {!isNaN(+ltcBalance) && !ltcBalanceLoading && ( + + + {ltcBalance} LTC + + + + )} + + + ) : ( + <> + + - {ltcBalance} LTC + {userInfo?.name} - + + + {rawWallet?.address0?.slice(0, 6)}... + {rawWallet?.address0?.slice(-4)} + + + + {qortBalanceLoading && ( + + )} + {!qortBalanceLoading && balance >= 0 && ( + + + {balance?.toFixed(2)} QORT + + + + )} + + + {userInfo && !userInfo?.name && ( + { + setOpenRegisterName(true); + }} + > + REGISTER NAME + + )} + + { + setIsOpenSendQort(true); + // setExtstate("send-qort"); + setIsOpenDrawerProfile(false); }} - /> - + > + Transfer QORT + + + )} - - - ) : ( - <> - - { + chrome.tabs.create({ url: "https://www.qort.trade" }); }} > - {userInfo?.name} + Get QORT at qort.trade - - - - {rawWallet?.address0?.slice(0, 6)}... - {rawWallet?.address0?.slice(-4)} - - - - {qortBalanceLoading && ( - - )} - {!qortBalanceLoading && balance >= 0 && ( - - + + + { + setExtstate("download-wallet"); + setIsOpenDrawerProfile(false); + }} + src={Download} + style={{ + cursor: "pointer", + }} + /> + {!isMobile && ( + <> + + { + logoutFunc(); + setIsOpenDrawerProfile(false); }} - > - {balance?.toFixed(2)} QORT - - - - )} - - - {userInfo && !userInfo?.name && ( - { - setOpenRegisterName(true); - }} - > - REGISTER NAME - + )} - { - setIsOpenSendQort(true) - // setExtstate("send-qort"); - setIsOpenDrawerProfile(false) + setIsSettingsOpen(true); }} > - Transfer QORT - - - - )} - { - chrome.tabs.create({ url: "https://www.qort.trade" }); - }} - > - Get QORT at qort.trade - - - - - { - setExtstate("download-wallet"); - setIsOpenDrawerProfile(false) - }} - src={Download} - style={{ - cursor: "pointer", - }} - /> - {!isMobile && ( - <> - - { - logoutFunc() - setIsOpenDrawerProfile(false) - }} - style={{ - cursor: "pointer", - }} - /> - - )} - - - { - setIsSettingsOpen(true) - }}> - - - - {authenticatedMode === "qort" && ( - { - setAuthenticatedMode("ltc"); - }} - src={ltcLogo} - style={{ - cursor: "pointer", - width: "20px", - height: "auto", - }} - /> - )} - {authenticatedMode === "ltc" && ( - { - setAuthenticatedMode("qort"); - }} - src={qortLogo} - style={{ - cursor: "pointer", - width: "20px", - height: "auto", - }} - /> - )} - - - - ) - } - - return ( - - {/* {extState === 'group' && ( - - )} */} - - {extState === "not-authenticated" && ( - <> - -
- - -
- - - WELCOME TO YOUR

- QORTAL WALLET -
- - - - - Authenticate - - - - - - - - - { - setExtstate("create-wallet"); - }} - > - Create account - - - - - - <> - - - - { - setOpenAdvancedSettings(true); - }} - > - Advanced settings - - - {openAdvancedSettings && ( - <> - - { - setUseLocalNode(event.target.checked); - }} - disabled={confirmUseOfLocal} - sx={{ - "&.Mui-checked": { - color: "white", // Customize the color when checked - }, - "& .MuiSvgIcon-root": { - color: "white", - }, - }} - /> + + + {authenticatedMode === "qort" && ( + { + setAuthenticatedMode("ltc"); + }} + src={ltcLogo} + style={{ + cursor: "pointer", + width: "20px", + height: "auto", + }} + /> + )} + {authenticatedMode === "ltc" && ( + { + setAuthenticatedMode("qort"); + }} + src={qortLogo} + style={{ + cursor: "pointer", + width: "20px", + height: "auto", + }} + /> + )} + + + ); + }; - Use local node - - {useLocalNode && ( - <> - - - - {apiKey} - - - - - )} - - )} - - - - + return ( + + + {extState === "not-authenticated" && ( + )} {/* {extState !== "not-authenticated" && ( )} */} - {extState === "authenticated" && isMainWindow && ( + {extState === "authenticated" && isMainWindow && ( - {!isMobile && renderProfile()} - + {(!isMobile && desktopViewMode !== 'apps') && renderProfile()} + + + + - - - - - - )} {isOpenSendQort && isMainWindow && ( - + )} + + {isShowQortalRequest && !isMainWindow && ( + <> + + + + {messageQortalRequest?.text1} + + + {messageQortalRequest?.text2 && ( + <> + + + + {messageQortalRequest?.text2} + + + + + )} + {messageQortalRequest?.text3 && ( + <> + + + {messageQortalRequest?.text3} + + + + + )} + + {messageQortalRequest?.text4 && ( + + + {messageQortalRequest?.text4} + + + )} + + {messageQortalRequest?.html && ( +
+ )} + + + + {messageQortalRequest?.highlightedText} + + + {messageQortalRequest?.fee && ( + <> + + + + {'Fee: '}{messageQortalRequest?.fee}{' QORT'} + + + + + )} + {messageQortalRequest?.checkbox1 && ( + + { + qortalRequestCheckbox1Ref.current = e.target.checked; + }} + edge="start" + tabIndex={-1} + disableRipple + defaultChecked={messageQortalRequest?.checkbox1?.value} + sx={{ + "&.Mui-checked": { + color: "white", // Customize the color when checked + }, + "& .MuiSvgIcon-root": { + color: "white", + }, + }} + /> + + + {messageQortalRequest?.checkbox1?.label} + + + )} + + + + onOkQortalRequest("accepted")} + > + accept + + onCancelQortalRequest()} + > + decline + + + {sendPaymentError} + + )} {extState === "web-app-request-buy-order" && !isMainWindow && ( <> @@ -1812,7 +1934,12 @@ function App() { > The Application

{" "} {requestBuyOrder?.hostname}

- is requesting {requestBuyOrder?.crosschainAtInfo?.length} {`buy order${requestBuyOrder?.crosschainAtInfo.length === 1 ? '' : 's'}`} + + is requesting {requestBuyOrder?.crosschainAtInfo?.length}{" "} + {`buy order${ + requestBuyOrder?.crosschainAtInfo.length === 1 ? "" : "s" + }`} + - {requestBuyOrder?.crosschainAtInfo?.reduce((latest, cur)=> { - return latest + +cur?.qortAmount - }, 0)} QORT + {requestBuyOrder?.crosschainAtInfo?.reduce((latest, cur) => { + return latest + +cur?.qortAmount; + }, 0)}{" "} + QORT - {roundUpToDecimals(requestBuyOrder?.crosschainAtInfo?.reduce((latest, cur)=> { - return latest + +cur?.expectedForeignAmount - }, 0))} + {roundUpToDecimals( + requestBuyOrder?.crosschainAtInfo?.reduce((latest, cur) => { + return latest + +cur?.expectedForeignAmount; + }, 0) + )} {` ${requestBuyOrder?.crosschainAtInfo?.[0]?.foreignBlockchain}`} {/* @@ -1890,6 +2020,7 @@ function App() { {sendPaymentError} )} + {extState === "web-app-request-payment" && !isMainWindow && ( <> @@ -2337,16 +2468,18 @@ function App() { )} {isOpenSendQortSuccess && ( - + @@ -2463,6 +2596,246 @@ function App() { + )} + {isShowUnsavedChanges && ( + + {"Warning"} + + + {messageUnsavedChanges.message} + + + + + + + + )} + {isShowQortalRequestExtension && isMainWindow && ( + + { + onCancelQortalRequestExtension() + }} + size={50} + strokeWidth={5} + > + {({ remainingTime }) => {remainingTime}} + + + + + {messageQortalRequestExtension?.text1} + + + {messageQortalRequestExtension?.text2 && ( + <> + + + + {messageQortalRequestExtension?.text2} + + + + + )} + {messageQortalRequestExtension?.text3 && ( + <> + + + {messageQortalRequestExtension?.text3} + + + + + )} + + {messageQortalRequestExtension?.text4 && ( + + + {messageQortalRequestExtension?.text4} + + + )} + + {messageQortalRequestExtension?.html && ( +
+ )} + + + + {messageQortalRequestExtension?.highlightedText} + + + {messageQortalRequestExtension?.fee && ( + <> + + + + {'Fee: '}{messageQortalRequestExtension?.fee}{' QORT'} + + + + + )} + {messageQortalRequestExtension?.checkbox1 && ( + + { + qortalRequestCheckbox1Ref.current = e.target.checked; + }} + edge="start" + tabIndex={-1} + disableRipple + defaultChecked={messageQortalRequestExtension?.checkbox1?.value} + sx={{ + "&.Mui-checked": { + color: "white", // Customize the color when checked + }, + "& .MuiSvgIcon-root": { + color: "white", + }, + }} + /> + + + {messageQortalRequestExtension?.checkbox1?.label} + + + )} + + + + onOkQortalRequestExtension("accepted")} + > + accept + + onCancelQortalRequestExtension()} + > + decline + + + {sendPaymentError} + +
)} {isSettingsOpen && ( - - + )} - {renderProfile()} + + {renderProfile()} + ); } diff --git a/src/ExtStates/NotAuthenticated.tsx b/src/ExtStates/NotAuthenticated.tsx new file mode 100644 index 0000000..44e8db7 --- /dev/null +++ b/src/ExtStates/NotAuthenticated.tsx @@ -0,0 +1,643 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Spacer } from "../common/Spacer"; +import { CustomButton, TextItalic, TextP, TextSpan } from "../App-styles"; +import { + Box, + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Input, + Switch, + Tooltip, + Typography, +} from "@mui/material"; +import Logo1 from "../assets/svgs/Logo1.svg"; +import Logo1Dark from "../assets/svgs/Logo1Dark.svg"; +import Info from "../assets/svgs/Info.svg"; +import { CustomizedSnackbars } from "../components/Snackbar/Snackbar"; +import { set } from "lodash"; +import { cleanUrl, isUsingLocal } from "../background"; + +const manifestData = chrome?.runtime?.getManifest(); + +export const NotAuthenticated = ({ + getRootProps, + getInputProps, + setExtstate, + + + apiKey, + setApiKey, + globalApiKey, + handleSetGlobalApikey, +}) => { + const [isValidApiKey, setIsValidApiKey] = useState(null); + const [hasLocalNode, setHasLocalNode] = useState(null); + const [useLocalNode, setUseLocalNode] = useState(false); + const [openSnack, setOpenSnack] = React.useState(false); + const [infoSnack, setInfoSnack] = React.useState(null); + const [show, setShow] = React.useState(false); + const [mode, setMode] = React.useState("list"); + const [customNodes, setCustomNodes] = React.useState(null); + const [currentNode, setCurrentNode] = React.useState({ + url: "http://127.0.0.1:12391", + }); + const [importedApiKey, setImportedApiKey] = React.useState(null); + //add and edit states + const [url, setUrl] = React.useState("http://"); + const [customApikey, setCustomApiKey] = React.useState(""); + const [customNodeToSaveIndex, setCustomNodeToSaveIndex] = + React.useState(null); + const importedApiKeyRef = useRef(null) + const currentNodeRef = useRef(null) + const hasLocalNodeRef = useRef(null) + const isLocal = cleanUrl(currentNode?.url) === "127.0.0.1:12391"; + const handleFileChangeApiKey = (event) => { + const file = event.target.files[0]; // Get the selected file + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target.result; // Get the file content + + setImportedApiKey(text); // Store the file content in the state + }; + reader.readAsText(file); // Read the file as text + } + }; + + const checkIfUserHasLocalNode = useCallback(async () => { + try { + const url = `http://127.0.0.1:12391/admin/status`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const data = await response.json(); + if (data?.height) { + setHasLocalNode(true); + } + } catch (error) {} + }, []); + + useEffect(() => { + checkIfUserHasLocalNode(); + }, []); + + useEffect(() => { + chrome?.runtime?.sendMessage( + { action: "getCustomNodesFromStorage" }, + (response) => { + if (response) { + setCustomNodes(response || []); + } + } + ); + }, []); + + useEffect(()=> { + importedApiKeyRef.current = importedApiKey + }, [importedApiKey]) + useEffect(()=> { + currentNodeRef.current = currentNode + }, [currentNode]) + + useEffect(()=> { + hasLocalNodeRef.current = hasLocalNode + }, [hasLocalNode]) + + const validateApiKey = useCallback(async (key, fromStartUp) => { + try { + if(!currentNodeRef.current) return + const isLocalKey = cleanUrl(key?.url) === "127.0.0.1:12391"; + if(isLocalKey && !hasLocalNodeRef.current && !fromStartUp){ + throw new Error('Please turn on your local node') + + } + const isCurrentNodeLocal = cleanUrl(currentNodeRef.current?.url) === "127.0.0.1:12391"; + if(isLocalKey && !isCurrentNodeLocal) { + setIsValidApiKey(false); + setUseLocalNode(false); + return + } + let payload = {}; + + if (currentNodeRef.current?.url === "http://127.0.0.1:12391") { + payload = { + apikey: importedApiKeyRef.current || key?.apikey, + url: currentNodeRef.current?.url, + }; + } else if(currentNodeRef.current) { + payload = currentNodeRef.current; + } + const url = `${payload?.url}/admin/apikey/test`; + const response = await fetch(url, { + method: "GET", + headers: { + accept: "text/plain", + "X-API-KEY": payload?.apikey, // Include the API key here + }, + }); + + // Assuming the response is in plain text and will be 'true' or 'false' + const data = await response.text(); + if (data === "true") { + chrome?.runtime?.sendMessage( + { action: "setApiKey", payload }, + (response) => { + if (response) { + handleSetGlobalApikey(payload); + setIsValidApiKey(true); + setUseLocalNode(true); + if(!fromStartUp){ + setApiKey(payload) + } + } + } + ); + } else { + setIsValidApiKey(false); + setUseLocalNode(false); + setInfoSnack({ + type: "error", + message: "Select a valid apikey", + }); + setOpenSnack(true); + } + } catch (error) { + setIsValidApiKey(false); + setUseLocalNode(false); + setInfoSnack({ + type: "error", + message: error?.message || "Select a valid apikey", + }); + setOpenSnack(true); + console.error("Error validating API key:", error); + } + }, []); + + useEffect(() => { + if (apiKey) { + validateApiKey(apiKey, true); + } + }, [apiKey]); + + const addCustomNode = () => { + setMode("add-node"); + }; + + const saveCustomNodes = (myNodes) => { + let nodes = [...(myNodes || [])]; + if (customNodeToSaveIndex !== null) { + nodes.splice(customNodeToSaveIndex, 1, { + url, + apikey: customApikey, + }); + } else if (url && customApikey) { + nodes.push({ + url, + apikey: customApikey, + }); + } + + setCustomNodes(nodes); + setCustomNodeToSaveIndex(null); + if (!nodes) return; + chrome?.runtime?.sendMessage( + { action: "setCustomNodes", nodes }, + (response) => { + if (response) { + setMode("list"); + setUrl("http://"); + setCustomApiKey(""); + // add alert + } + } + ); + }; + + + return ( + <> + +
+ + +
+ + + WELCOME TO YOUR

+ QORTAL WALLET +
+ + + + + Authenticate + + + + + + + + + { + setExtstate("create-wallet"); + }} + > + Create account + + + + + + + + {"Using node: "} {currentNode?.url} + + <> + + + <> + + { + if (event.target.checked) { + validateApiKey(currentNode); + } else { + setCurrentNode({ + url: "http://127.0.0.1:12391", + }) + setUseLocalNode(false) + chrome?.runtime?.sendMessage( + { action: "setApiKey", payload:null }, + (response) => { + if (response) { + setApiKey(null); + handleSetGlobalApikey(null); + + } + } + ); + } + + }} + disabled={false} + defaultChecked + /> + } + label={`Use ${isLocal ? 'Local' : 'Custom'} Node`} + /> + + {currentNode?.url === "http://127.0.0.1:12391" && ( + <> + + {`api key : ${importedApiKey}`} + + + + + + )} + + + Build version: {manifestData?.version} + + + + {show && ( + + {"Custom nodes"} + + + + {mode === "list" && ( + + + + http://127.0.0.1:12391 + + + + + + + {customNodes?.map((node, index) => { + return ( + + + {node?.url} + + + + + + + + ); + })} + + )} + {mode === "add-node" && ( + + { + setUrl(e.target.value); + }} + /> + { + setCustomApiKey(e.target.value); + }} + /> + + )} + + + + + + {mode === "list" && ( + <> + + + )} + {mode === "list" && ( + + )} + + {mode === "add-node" && ( + <> + + + + + )} + + + )} + + ); +}; diff --git a/src/assets/svgs/AppIcon.svg b/src/assets/svgs/AppIcon.svg new file mode 100644 index 0000000..9bd319b --- /dev/null +++ b/src/assets/svgs/AppIcon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/svgs/ClearInput.svg b/src/assets/svgs/ClearInput.svg new file mode 100644 index 0000000..a4595df --- /dev/null +++ b/src/assets/svgs/ClearInput.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/svgs/LogoSelected.svg b/src/assets/svgs/LogoSelected.svg new file mode 100644 index 0000000..fec7e1e --- /dev/null +++ b/src/assets/svgs/LogoSelected.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/svgs/NavAdd.svg b/src/assets/svgs/NavAdd.svg new file mode 100644 index 0000000..1d38c05 --- /dev/null +++ b/src/assets/svgs/NavAdd.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/NavBack.svg b/src/assets/svgs/NavBack.svg new file mode 100644 index 0000000..07df29e --- /dev/null +++ b/src/assets/svgs/NavBack.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/svgs/NavCloseTab.svg b/src/assets/svgs/NavCloseTab.svg new file mode 100644 index 0000000..a4595df --- /dev/null +++ b/src/assets/svgs/NavCloseTab.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/svgs/NavMoreMenu.svg b/src/assets/svgs/NavMoreMenu.svg new file mode 100644 index 0000000..f64cdab --- /dev/null +++ b/src/assets/svgs/NavMoreMenu.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/svgs/SaveIcon.tsx b/src/assets/svgs/SaveIcon.tsx new file mode 100644 index 0000000..12c4999 --- /dev/null +++ b/src/assets/svgs/SaveIcon.tsx @@ -0,0 +1,10 @@ +import React from 'react' + +export const SaveIcon = ({color = '#8F8F91'}) => { + return ( + + + + + ) +} diff --git a/src/assets/svgs/Search.svg b/src/assets/svgs/Search.svg new file mode 100644 index 0000000..b6cb06b --- /dev/null +++ b/src/assets/svgs/Search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/StarEmpty.tsx b/src/assets/svgs/StarEmpty.tsx new file mode 100644 index 0000000..8375111 --- /dev/null +++ b/src/assets/svgs/StarEmpty.tsx @@ -0,0 +1,16 @@ + + + +import React from 'react'; + + +export const StarEmptyIcon = () => { + return ( + + + + + + + ); +}; diff --git a/src/assets/svgs/StarFilled.tsx b/src/assets/svgs/StarFilled.tsx new file mode 100644 index 0000000..fb82e49 --- /dev/null +++ b/src/assets/svgs/StarFilled.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +export const StarFilledIcon = () => { + return ( + + + + ); +}; diff --git a/src/assets/svgs/qappDevelopText.svg b/src/assets/svgs/qappDevelopText.svg new file mode 100644 index 0000000..3aa786a --- /dev/null +++ b/src/assets/svgs/qappDevelopText.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/qappDots.svg b/src/assets/svgs/qappDots.svg new file mode 100644 index 0000000..086de81 --- /dev/null +++ b/src/assets/svgs/qappDots.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/qappLibraryText.svg b/src/assets/svgs/qappLibraryText.svg new file mode 100644 index 0000000..297c466 --- /dev/null +++ b/src/assets/svgs/qappLibraryText.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/atoms/global.ts b/src/atoms/global.ts new file mode 100644 index 0000000..67d3aa5 --- /dev/null +++ b/src/atoms/global.ts @@ -0,0 +1,64 @@ +import { atom } from 'recoil'; + + +export const sortablePinnedAppsAtom = atom({ + key: 'sortablePinnedAppsFromAtom', + default: [{ + name: 'Q-Tube', + service: 'APP' + }, { + name: 'Q-Mail', + service: 'APP' + }, { + name: 'Q-Share', + service: 'APP' + }, { + name: 'Q-Blog', + service: 'APP' + }, { + name: 'Q-Fund', + service: 'APP' + }, { + name: 'Q-Shop', + service: 'APP' + },{ + name: 'Qombo', + service: 'APP' + } +], +}); + +export const canSaveSettingToQdnAtom = atom({ + key: 'canSaveSettingToQdnAtom', + default: false, +}); + +export const settingsQDNLastUpdatedAtom = atom({ + key: 'settingsQDNLastUpdatedAtom', + default: -100, +}); + +export const settingsLocalLastUpdatedAtom = atom({ + key: 'settingsLocalLastUpdatedAtom', + default: 0, +}); + +export const oldPinnedAppsAtom = atom({ + key: 'oldPinnedAppsAtom', + default: [], +}); + +export const fullScreenAtom = atom({ + key: 'fullScreenAtom', + default: false, +}); + +export const hasSettingsChangedAtom = atom({ + key: 'hasSettingsChangedAtom', + default: false, +}); + +export const navigationControllerAtom = atom({ + key: 'navigationControllerAtom', + default: {}, +}); \ No newline at end of file diff --git a/src/background.ts b/src/background.ts index adccae7..2f5f5c1 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,5 +1,7 @@ // @ts-nocheck // import { encryptAndPublishSymmetricKeyGroupChat } from "./backgroundFunctions/encryption"; + +import './qortalRequests' import { constant, isArray } from "lodash"; import { decryptGroupEncryption, @@ -29,6 +31,19 @@ import { Sha256 } from "asmcrypto.js"; import { TradeBotRespondMultipleRequest } from "./transactions/TradeBotRespondMultipleRequest"; import { RESOURCE_TYPE_NUMBER_GROUP_CHAT_REACTIONS } from "./constants/resourceTypes"; +export function cleanUrl(url) { + return url?.replace(/^(https?:\/\/)?(www\.)?/, ''); +} +export function getProtocol(url) { + if (url?.startsWith('https://')) { + return 'https'; + } else if (url?.startsWith('http://')) { + return 'http'; + } else { + return 'unknown'; // If neither protocol is present + } +} + let lastGroupNotification; export const groupApi = "https://ext-node.qortal.link"; export const groupApiSocket = "wss://ext-node.qortal.link"; @@ -38,6 +53,7 @@ const timeDifferenceForNotificationChatsBackground = 600000; const requestQueueAnnouncements = new RequestQueueWithPromise(1); let isMobile = false; + const isMobileDevice = () => { const userAgent = navigator.userAgent || navigator.vendor || window.opera; @@ -104,6 +120,17 @@ const getApiKeyFromStorage = async () => { }); }; +const getCustomNodesFromStorage = async () => { + return new Promise((resolve, reject) => { + chrome.storage.local.get("customNodes", (result) => { + if (chrome.runtime.lastError) { + return reject(chrome.runtime.lastError); + } + resolve(result.customNodes || null); // Return null if apiKey isn't found + }); + }); +}; + // const getArbitraryEndpoint = ()=> { // const apiKey = await getApiKeyFromStorage(); // Retrieve apiKey asynchronously // if (apiKey) { @@ -115,7 +142,7 @@ const getApiKeyFromStorage = async () => { const getArbitraryEndpoint = async () => { const apiKey = await getApiKeyFromStorage(); // Retrieve apiKey asynchronously if (apiKey) { - return `/arbitrary/resources/search`; + return `/arbitrary/resources/searchsimple`; } else { return `/arbitrary/resources/searchsimple`; } @@ -128,23 +155,24 @@ export const getBaseApi = async (customApi?: string) => { const apiKey = await getApiKeyFromStorage(); // Retrieve apiKey asynchronously if (apiKey) { - return groupApiLocal; + return apiKey?.url; } else { return groupApi; } }; +export const isUsingLocal = async () => { -export const createEndpointSocket = async (endpoint) => { const apiKey = await getApiKeyFromStorage(); // Retrieve apiKey asynchronously - if (apiKey) { - return `${groupApiSocketLocal}${endpoint}`; + return true } else { - return `${groupApiSocket}${endpoint}`; + return false; } }; -export const createEndpoint = async (endpoint, customApi) => { + + +export const createEndpoint = async (endpoint, customApi?: string) => { if (customApi) { return `${customApi}${endpoint}`; } @@ -154,7 +182,7 @@ export const createEndpoint = async (endpoint, customApi) => { if (apiKey) { // Check if the endpoint already contains a query string const separator = endpoint.includes("?") ? "&" : "?"; - return `${groupApiLocal}${endpoint}${separator}apiKey=${apiKey}`; + return `${apiKey?.url}${endpoint}${separator}apiKey=${apiKey?.apikey}`; } else { return `${groupApi}${endpoint}`; } @@ -949,7 +977,7 @@ async function getAddressInfo(address) { return data; } -async function getKeyPair() { +export async function getKeyPair() { const res = await chrome.storage.local.get(["keyPair"]); if (res?.keyPair) { return res.keyPair; @@ -958,7 +986,7 @@ async function getKeyPair() { } } -async function getSaveWallet() { +export async function getSaveWallet() { const res = await chrome.storage.local.get(["walletInfo"]); if (res?.walletInfo) { return res.walletInfo; @@ -1007,7 +1035,7 @@ async function getTradesInfo(qortalAtAddresses) { return trades; // Return the array of trade info objects } -async function getBalanceInfo() { +export async function getBalanceInfo() { const wallet = await getSaveWallet(); const address = wallet.address0; const validApi = await getBaseApi(); @@ -1057,7 +1085,7 @@ const processTransactionVersion2Chat = async (body: any, customApi) => { }); }; -const processTransactionVersion2 = async (body: any) => { +export const processTransactionVersion2 = async (body: any) => { const url = await createEndpoint(`/transactions/process?apiVersion=2`); try { @@ -1139,7 +1167,7 @@ const makeTransactionRequest = async ( return myTxnrequest; }; -const getLastRef = async () => { +export const getLastRef = async () => { const wallet = await getSaveWallet(); const address = wallet.address0; const validApi = await getBaseApi(); @@ -1150,7 +1178,7 @@ const getLastRef = async () => { const data = await response.text(); return data; }; -const sendQortFee = async () => { +export const sendQortFee = async (): Promise => { const validApi = await getBaseApi(); const response = await fetch( validApi + "/transactions/unitfee?txType=PAYMENT" @@ -1350,6 +1378,24 @@ async function decryptWallet({ password, wallet, walletVersion }) { publicKey: Base58.encode(keyPair.publicKey), ltcPrivateKey: ltcPrivateKey, ltcPublicKey: ltcPublicKey, + arrrSeed58: wallet2._addresses[0].arrrWallet.seed58, + btcAddress: wallet2._addresses[0].btcWallet.address, + btcPublicKey: wallet2._addresses[0].btcWallet.derivedMasterPublicKey, + btcPrivateKey: wallet2._addresses[0].btcWallet.derivedMasterPrivateKey, + + ltcAddress: wallet2._addresses[0].ltcWallet.address, + + dogeAddress: wallet2._addresses[0].dogeWallet.address, + dogePublicKey: wallet2._addresses[0].dogeWallet.derivedMasterPublicKey, + dogePrivateKey: wallet2._addresses[0].dogeWallet.derivedMasterPrivateKey, + + dgbAddress: wallet2._addresses[0].dgbWallet.address, + dgbPublicKey: wallet2._addresses[0].dgbWallet.derivedMasterPublicKey, + dgbPrivateKey: wallet2._addresses[0].dgbWallet.derivedMasterPrivateKey, + + rvnAddress: wallet2._addresses[0].rvnWallet.address, + rvnPublicKey: wallet2._addresses[0].rvnWallet.derivedMasterPublicKey, + rvnPrivateKey: wallet2._addresses[0].rvnWallet.derivedMasterPrivateKey }; const dataString = JSON.stringify(toSave); await new Promise((resolve, reject) => { @@ -1382,7 +1428,7 @@ async function decryptWallet({ password, wallet, walletVersion }) { } } -async function signChatFunc(chatBytesArray, chatNonce, customApi, keyPair) { +export async function signChatFunc(chatBytesArray, chatNonce, customApi, keyPair) { let response; try { const signedChatBytes = signChat(chatBytesArray, chatNonce, keyPair); @@ -1407,7 +1453,7 @@ function sbrk(size, heap) { return old; } -const computePow = async ({ chatBytes, path, difficulty }) => { +export const computePow = async ({ chatBytes, path, difficulty }) => { let response = null; await new Promise((resolve, reject) => { const _chatBytesArray = Object.keys(chatBytes).map(function (key) { @@ -1787,7 +1833,7 @@ async function createBuyOrderTx({ crosschainAtInfo, useLocal }) { let responseVar const txn = new TradeBotRespondMultipleRequest().createTransaction(message) const apiKey = await getApiKeyFromStorage(); - const responseFetch = await fetch(`http://127.0.0.1:12391/crosschain/tradebot/respondmultiple?apiKey=${apiKey}`, { + const responseFetch = await fetch(`${apiKey?.url}/crosschain/tradebot/respondmultiple?apiKey=${apiKey?.apikey}`, { method: "POST", headers: { "Content-Type": "application/json", @@ -1971,7 +2017,7 @@ async function leaveGroup({ groupId }) { return res; } -async function joinGroup({ groupId }) { +export async function joinGroup({ groupId }) { const wallet = await getSaveWallet(); const address = wallet.address0; const lastReference = await getLastRef(); @@ -2266,7 +2312,7 @@ async function inviteToGroup({ groupId, qortalAddress, inviteTime }) { return res; } -async function sendCoin({ password, amount, receiver }, skipConfirmPassword) { +export async function sendCoin({ password, amount, receiver }, skipConfirmPassword) { try { const confirmReceiver = await getNameOrAddress(receiver); if (confirmReceiver.error) @@ -2498,7 +2544,7 @@ async function listenForChatMessageForBuyOrder({ } } -function removeDuplicateWindow(popupUrl) { +export function removeDuplicateWindow(popupUrl) { chrome.windows.getAll( { populate: true, windowTypes: ["popup"] }, (windows) => { @@ -2800,6 +2846,9 @@ async function getChatHeadsDirect() { } chrome?.runtime?.onMessage.addListener((request, sender, sendResponse) => { if (request) { + + console.log('REQUEST MESSAGE', request) + switch (request.action) { case "version": // Example: respond with the version @@ -3259,6 +3308,16 @@ chrome?.runtime?.onMessage.addListener((request, sender, sendResponse) => { return true; break; } + case "setCustomNodes": { + const { nodes } = request; + + // Save the customNodes in chrome.storage.local for persistence + chrome.storage.local.set({ customNodes: nodes }, () => { + sendResponse(true); + }); + return true; + break; + } case "getApiKey": { getApiKeyFromStorage() .then((res) => { @@ -3271,6 +3330,19 @@ chrome?.runtime?.onMessage.addListener((request, sender, sendResponse) => { return true; break; } + case "getCustomNodesFromStorage": { + getCustomNodesFromStorage() + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + console.error(error.message); + }); + return true; + break; + } + case "notifyAdminRegenerateSecretKey": { const { groupName, adminAddress } = request.payload; notifyAdminRegenerateSecretKey({ groupName, adminAddress }) @@ -3873,19 +3945,35 @@ chrome?.runtime?.onMessage.addListener((request, sender, sendResponse) => { break; } case "publishOnQDN": { - const { data, identifier, service } = request.payload; + const { data, identifier, service, title, + description, + category, + tag1, + tag2, + tag3, + tag4, + tag5, uploadType } = request.payload; publishOnQDN({ data, identifier, - service + service, + title, + description, + category, + tag1, + tag2, + tag3, + tag4, + tag5, + uploadType }) .then((data) => { sendResponse(data); }) .catch((error) => { - console.error(error.message); - sendResponse({ error: error.message }); + console.error(error?.message); + sendResponse({ error: error?.message || 'Unable to publish' }); }); return true; break; @@ -4136,7 +4224,6 @@ chrome?.runtime?.onMessage.addListener((request, sender, sendResponse) => { [ "keyPair", "walletInfo", - "apiKey", "active-groups-directs", key1, key2, diff --git a/src/backgroundFunctions/encryption.ts b/src/backgroundFunctions/encryption.ts index 54640f1..a78f479 100644 --- a/src/backgroundFunctions/encryption.ts +++ b/src/backgroundFunctions/encryption.ts @@ -43,7 +43,7 @@ async function getSaveWallet() { throw new Error("No wallet saved"); } } -async function getNameInfo() { +export async function getNameInfo() { const wallet = await getSaveWallet(); const address = wallet.address0; const validApi = await getBaseApi() @@ -152,23 +152,40 @@ export const publishGroupEncryptedResource = async ({encryptedData, identifier}) throw new Error(error.message); } } -export const publishOnQDN = async ({data, identifier, service}) => { - try { - - if(data && identifier && service){ +export const publishOnQDN = async ({data, identifier, service, title, + description, + category, + tag1, + tag2, + tag3, + tag4, + tag5, + uploadType = 'file' +}) => { + + if(data && service){ const registeredName = await getNameInfo() if(!registeredName) throw new Error('You need a name to publish') - const res = await publishData({ - registeredName, file: data, service, identifier, uploadType: 'file', isBase64: true, withFee: true + + const res = await publishData({ + registeredName, file: data, service, identifier, uploadType, isBase64: true, withFee: true, title, + description, + category, + tag1, + tag2, + tag3, + tag4, + tag5 + }) return res + + } else { - throw new Error('Cannot encrypt content') + throw new Error('Cannot publish content') } - } catch (error: any) { - throw new Error(error.message); - } + } export function uint8ArrayToBase64(uint8Array: any) { diff --git a/src/common/Spacer.tsx b/src/common/Spacer.tsx index 3a387a7..b1a2743 100644 --- a/src/common/Spacer.tsx +++ b/src/common/Spacer.tsx @@ -1,12 +1,14 @@ import { Box } from "@mui/material"; -export const Spacer = ({ height }: any) => { +export const Spacer = ({ height, width, ...props }: any) => { return ( ); diff --git a/src/components/Apps/AppInfo.tsx b/src/components/Apps/AppInfo.tsx new file mode 100644 index 0000000..ad36163 --- /dev/null +++ b/src/components/Apps/AppInfo.tsx @@ -0,0 +1,210 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + AppCircle, + AppCircleContainer, + AppCircleLabel, + AppDownloadButton, + AppDownloadButtonText, + AppInfoAppName, + AppInfoSnippetContainer, + AppInfoSnippetLeft, + AppInfoSnippetMiddle, + AppInfoSnippetRight, + AppInfoUserName, + AppsCategoryInfo, + AppsCategoryInfoLabel, + AppsCategoryInfoSub, + AppsCategoryInfoValue, + AppsInfoDescription, + AppsLibraryContainer, + AppsParent, + AppsWidthLimiter, +} from "./Apps-styles"; +import { Avatar, Box, ButtonBase, InputBase } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import { getBaseApiReact, isMobile } from "../../App"; +import LogoSelected from "../../assets/svgs/LogoSelected.svg"; + +import { Spacer } from "../../common/Spacer"; +import { executeEvent } from "../../utils/events"; +import { AppRating } from "./AppRating"; +import { settingsLocalLastUpdatedAtom, sortablePinnedAppsAtom } from "../../atoms/global"; +import { saveToLocalStorage } from "./AppsNavBar"; +import { useRecoilState, useSetRecoilState } from "recoil"; + +export const AppInfo = ({ app, myName }) => { + const isInstalled = app?.status?.status === "READY"; + const [sortablePinnedApps, setSortablePinnedApps] = useRecoilState(sortablePinnedAppsAtom); + + const isSelectedAppPinned = !!sortablePinnedApps?.find((item)=> item?.name === app?.name && item?.service === app?.service) + const setSettingsLocalLastUpdated = useSetRecoilState(settingsLocalLastUpdatedAtom); + + return ( + + + + + {!isMobile && } + + + + + + + center-icon + + + + + + {app?.metadata?.title || app?.name} + + + {app?.name} + + + + + + + + { + setSortablePinnedApps((prev) => { + let updatedApps; + + if (isSelectedAppPinned) { + // Remove the selected app if it is pinned + updatedApps = prev.filter( + (item) => !(item?.name === app?.name && item?.service === app?.service) + ); + } else { + // Add the selected app if it is not pinned + updatedApps = [...prev, { + name: app?.name, + service: app?.service, + }]; + } + + saveToLocalStorage('ext_saved_settings', 'sortablePinnedApps', updatedApps) + return updatedApps; + }); + setSettingsLocalLastUpdated(Date.now()) + }} + sx={{ + backgroundColor: "#359ff7ff", + width: "100%", + maxWidth: "320px", + height: "29px", + opacity: isSelectedAppPinned ? 0.6 : 1 + }} + > + + {!isMobile ? ( + <> + {isSelectedAppPinned ? 'Unpin from dashboard' : 'Pin to dashboard'} + + ) : ( + <> + {isSelectedAppPinned ? 'Unpin' : 'Pin'} + + )} + + + + { + executeEvent("addTab", { + data: app, + }); + }} + sx={{ + backgroundColor: isInstalled ? "#0091E1" : "#247C0E", + width: "100%", + maxWidth: "320px", + height: "29px", + }} + > + + {isInstalled ? "Open" : "Download"} + + + + + + + + + + + + + + Category: + + + {app?.metadata?.categoryName || "none"} + + + + + About this Q-App + + + + {app?.metadata?.description || "No description"} + + + + ); +}; diff --git a/src/components/Apps/AppInfoSnippet.tsx b/src/components/Apps/AppInfoSnippet.tsx new file mode 100644 index 0000000..01a7083 --- /dev/null +++ b/src/components/Apps/AppInfoSnippet.tsx @@ -0,0 +1,157 @@ +import React from "react"; +import { + AppCircle, + AppCircleContainer, + AppDownloadButton, + AppDownloadButtonText, + AppInfoAppName, + AppInfoSnippetContainer, + AppInfoSnippetLeft, + AppInfoSnippetMiddle, + AppInfoSnippetRight, + AppInfoUserName, +} from "./Apps-styles"; +import { Avatar, ButtonBase } from "@mui/material"; +import { getBaseApiReact, isMobile } from "../../App"; +import LogoSelected from "../../assets/svgs/LogoSelected.svg"; + +import { Spacer } from "../../common/Spacer"; +import { executeEvent } from "../../utils/events"; +import { AppRating } from "./AppRating"; +import { useRecoilState, useSetRecoilState } from "recoil"; +import { settingsLocalLastUpdatedAtom, sortablePinnedAppsAtom } from "../../atoms/global"; +import { saveToLocalStorage } from "./AppsNavBar"; + +export const AppInfoSnippet = ({ app, myName, isFromCategory }) => { + + const isInstalled = app?.status?.status === 'READY' + const [sortablePinnedApps, setSortablePinnedApps] = useRecoilState(sortablePinnedAppsAtom); + + const isSelectedAppPinned = !!sortablePinnedApps?.find((item)=> item?.name === app?.name && item?.service === app?.service) + const setSettingsLocalLastUpdated = useSetRecoilState(settingsLocalLastUpdatedAtom); + return ( + + + { + if(isFromCategory){ + executeEvent("selectedAppInfoCategory", { + data: app, + }); + return + } + executeEvent("selectedAppInfo", { + data: app, + }); + }} + > + + + + center-icon + + + + + + + { + if(isFromCategory){ + executeEvent("selectedAppInfoCategory", { + data: app, + }); + return + } + executeEvent("selectedAppInfo", { + data: app, + }); + }}> + + {app?.metadata?.title || app?.name} + + + + + { app?.name} + + + + + + + {!isMobile && ( + { + + setSortablePinnedApps((prev) => { + let updatedApps; + + if (isSelectedAppPinned) { + // Remove the selected app if it is pinned + updatedApps = prev.filter( + (item) => !(item?.name === app?.name && item?.service === app?.service) + ); + } else { + // Add the selected app if it is not pinned + updatedApps = [...prev, { + name: app?.name, + service: app?.service, + }]; + } + + saveToLocalStorage('ext_saved_settings', 'sortablePinnedApps', updatedApps) + return updatedApps; + }); + setSettingsLocalLastUpdated(Date.now()) + }} sx={{ + backgroundColor: '#359ff7ff', + opacity: isSelectedAppPinned ? 0.6 : 1 + + }}> + {isSelectedAppPinned ? 'Unpin' : 'Pin'} + + )} + + { + + executeEvent("addTab", { + data: app + }) + }} sx={{ + backgroundColor: isInstalled ? '#0091E1' : '#247C0E', + + }}> + {isInstalled ? 'Open' : 'Download'} + + + + ); +}; diff --git a/src/components/Apps/AppPublish.tsx b/src/components/Apps/AppPublish.tsx new file mode 100644 index 0000000..9736bfe --- /dev/null +++ b/src/components/Apps/AppPublish.tsx @@ -0,0 +1,519 @@ +import React, { useContext, useEffect, useMemo, useState } from "react"; +import { + AppCircle, + AppCircleContainer, + AppCircleLabel, + AppDownloadButton, + AppDownloadButtonText, + AppInfoAppName, + AppInfoSnippetContainer, + AppInfoSnippetLeft, + AppInfoSnippetMiddle, + AppInfoSnippetRight, + AppInfoUserName, + AppLibrarySubTitle, + AppPublishTagsContainer, + AppsLibraryContainer, + AppsParent, + AppsWidthLimiter, + PublishQAppCTAButton, + PublishQAppChoseFile, + PublishQAppInfo, +} from "./Apps-styles"; +import { + Avatar, + Box, + ButtonBase, + InputBase, + InputLabel, + MenuItem, + Select, +} from "@mui/material"; +import { + Select as BaseSelect, + SelectProps, + selectClasses, + SelectRootSlotProps, +} from "@mui/base/Select"; +import { Option as BaseOption, optionClasses } from "@mui/base/Option"; +import { styled } from "@mui/system"; +import UnfoldMoreRoundedIcon from "@mui/icons-material/UnfoldMoreRounded"; +import { Add } from "@mui/icons-material"; +import { MyContext, getBaseApiReact, isMobile } from "../../App"; +import LogoSelected from "../../assets/svgs/LogoSelected.svg"; + +import { Spacer } from "../../common/Spacer"; +import { executeEvent } from "../../utils/events"; +import { useDropzone } from "react-dropzone"; +import { LoadingSnackbar } from "../Snackbar/LoadingSnackbar"; +import { CustomizedSnackbars } from "../Snackbar/Snackbar"; +import { getFee } from "../../background"; +import { fileToBase64 } from "../../utils/fileReading"; + +const CustomSelect = styled(Select)({ + border: "0.5px solid var(--50-white, #FFFFFF80)", + padding: "0px 15px", + borderRadius: "5px", + height: "36px", + width: "100%", + maxWidth: "450px", + "& .MuiSelect-select": { + padding: "0px", + }, + "&:hover": { + borderColor: "none", // Border color on hover + }, + "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: "none", // Border color when focused + }, + "&.Mui-disabled": { + opacity: 0.5, // Lower opacity when disabled + }, + "& .MuiSvgIcon-root": { + color: "var(--50-white, #FFFFFF80)", + }, +}); + +const CustomMenuItem = styled(MenuItem)({ + backgroundColor: "#1f1f1f", // Background for dropdown items + color: "#ccc", + "&:hover": { + backgroundColor: "#333", // Darker background on hover + }, +}); + +export const AppPublish = ({ names, categories }) => { + const [name, setName] = useState(""); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [category, setCategory] = useState(""); + const [appType, setAppType] = useState("APP"); + const [file, setFile] = useState(null); + const { show } = useContext(MyContext); + + const [tag1, setTag1] = useState(""); + const [tag2, setTag2] = useState(""); + const [tag3, setTag3] = useState(""); + const [tag4, setTag4] = useState(""); + const [tag5, setTag5] = useState(""); + const [openSnack, setOpenSnack] = useState(false); + const [infoSnack, setInfoSnack] = useState(null); + const [isLoading, setIsLoading] = useState(""); + const maxFileSize = appType === "APP" ? 50 * 1024 * 1024 : 400 * 1024 * 1024; // 50MB or 400MB + const { getRootProps, getInputProps } = useDropzone({ + accept: { + "application/zip": [".zip"], // Only accept zip files + }, + maxSize: maxFileSize, // Set the max size based on appType + multiple: false, // Disable multiple file uploads + onDrop: (acceptedFiles) => { + if (acceptedFiles.length > 0) { + setFile(acceptedFiles[0]); // Set the file name + } + }, + onDropRejected: (fileRejections) => { + fileRejections.forEach(({ file, errors }) => { + errors.forEach((error) => { + if (error.code === "file-too-large") { + console.error( + `File ${file.name} is too large. Max size allowed is ${ + maxFileSize / (1024 * 1024) + } MB.` + ); + } + }); + }); + }, + }); + + const getQapp = React.useCallback(async (name, appType) => { + try { + setIsLoading("Loading app information"); + const url = `${getBaseApiReact()}/arbitrary/resources/search?service=${appType}&mode=ALL&name=${name}&includemetadata=true`; + + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + if (!response?.ok) return; + const responseData = await response.json(); + + if (responseData?.length > 0) { + const myApp = responseData[0]; + setTitle(myApp?.metadata?.title || ""); + setDescription(myApp?.metadata?.description || ""); + setCategory(myApp?.metadata?.category || ""); + setTag1(myApp?.metadata?.tags[0] || ""); + setTag2(myApp?.metadata?.tags[1] || ""); + setTag3(myApp?.metadata?.tags[2] || ""); + setTag4(myApp?.metadata?.tags[3] || ""); + setTag5(myApp?.metadata?.tags[4] || ""); + } + } catch (error) { + } finally { + setIsLoading(""); + } + }, []); + + useEffect(() => { + if (!name || !appType) return; + getQapp(name, appType); + }, [name, appType]); + + const publishApp = async () => { + try { + const data = { + name, + title, + description, + category, + appType, + file, + }; + const requiredFields = [ + "name", + "title", + "description", + "category", + "appType", + "file", + ]; + + 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 fee = await getFee("ARBITRARY"); + + await show({ + message: "Would you like to publish this app?", + publishFee: fee.fee + " QORT", + }); + setIsLoading("Publishing... Please wait."); + const fileBase64 = await fileToBase64(file); + await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "publishOnQDN", + payload: { + data: fileBase64, + service: appType, + title, + description, + category, + tag1, + tag2, + tag3, + tag4, + tag5, + uploadType: 'zip' + }, + }, + (response) => { + if (!response?.error) { + res(response); + return; + } + rej(response.error); + } + ); + }); + setInfoSnack({ + type: "success", + message: + "Successfully published. Please wait a couple minutes for the network to propogate the changes.", + }); + setOpenSnack(true); + const dataObj = { + name: name, + service: appType, + metadata: { + title: title, + description: description, + category: category, + }, + created: Date.now(), + }; + executeEvent("addTab", { + data: dataObj, + }); + } catch (error) { + setInfoSnack({ + type: "error", + message: error?.message || "Unable to publish app", + }); + setOpenSnack(true); + } finally { + setIsLoading(""); + } + }; + return ( + + + Create Apps! + + + Note: Currently, only one App and Website is allowed per Name. + + + Name/App + setName(event?.target.value)} + > + + + Select Name/App + {" "} + {/* This is the placeholder item */} + + {names.map((name) => { + return {name}; + })} + + + App service type + setAppType(event?.target.value)} + > + + + Select App Type + {" "} + {/* This is the placeholder item */} + + App + Website + + + Title + setTitle(e.target.value)} + sx={{ + border: "0.5px solid var(--50-white, #FFFFFF80)", + padding: "0px 15px", + borderRadius: "5px", + height: "36px", + width: "100%", + maxWidth: "450px", + }} + placeholder="Title" + inputProps={{ + "aria-label": "Title", + fontSize: "14px", + fontWeight: 400, + }} + /> + + Description + setDescription(e.target.value)} + sx={{ + border: "0.5px solid var(--50-white, #FFFFFF80)", + padding: "0px 15px", + borderRadius: "5px", + height: "36px", + width: "100%", + maxWidth: "450px", + }} + placeholder="Description" + inputProps={{ + "aria-label": "Description", + fontSize: "14px", + fontWeight: 400, + }} + /> + + + Category + setCategory(event?.target.value)} + > + + + Select Category + {" "} + {/* This is the placeholder item */} + + {categories?.map((category) => { + return ( + + {category?.name} + + ); + })} + + + Tags + + setTag1(e.target.value)} + sx={{ + border: "0.5px solid var(--50-white, #FFFFFF80)", + padding: "0px 15px", + borderRadius: "5px", + height: "36px", + width: "100px", + }} + placeholder="Tag 1" + inputProps={{ + "aria-label": "Tag 1", + fontSize: "14px", + fontWeight: 400, + }} + /> + setTag2(e.target.value)} + sx={{ + border: "0.5px solid var(--50-white, #FFFFFF80)", + padding: "0px 15px", + borderRadius: "5px", + height: "36px", + width: "100px", + }} + placeholder="Tag 2" + inputProps={{ + "aria-label": "Tag 2", + fontSize: "14px", + fontWeight: 400, + }} + /> + setTag3(e.target.value)} + sx={{ + border: "0.5px solid var(--50-white, #FFFFFF80)", + padding: "0px 15px", + borderRadius: "5px", + height: "36px", + width: "100px", + }} + placeholder="Tag 3" + inputProps={{ + "aria-label": "Tag 3", + fontSize: "14px", + fontWeight: 400, + }} + /> + setTag4(e.target.value)} + sx={{ + border: "0.5px solid var(--50-white, #FFFFFF80)", + padding: "0px 15px", + borderRadius: "5px", + height: "36px", + width: "100px", + }} + placeholder="Tag 4" + inputProps={{ + "aria-label": "Tag 4", + fontSize: "14px", + fontWeight: 400, + }} + /> + setTag5(e.target.value)} + sx={{ + border: "0.5px solid var(--50-white, #FFFFFF80)", + padding: "0px 15px", + borderRadius: "5px", + height: "36px", + width: "100px", + }} + placeholder="Tag 5" + inputProps={{ + "aria-label": "Tag 5", + fontSize: "14px", + fontWeight: 400, + }} + /> + + + + Select .zip file containing static content:{" "} + + + {`(${ + appType === "APP" ? "50mb" : "400mb" + } MB maximum)`} + {file && ( + <> + + {`Selected: (${file?.name})`} + + )} + + + + {" "} + + Choose File + + + + Publish + + + + + + + ); +}; diff --git a/src/components/Apps/AppRating.tsx b/src/components/Apps/AppRating.tsx new file mode 100644 index 0000000..a498779 --- /dev/null +++ b/src/components/Apps/AppRating.tsx @@ -0,0 +1,243 @@ +import { Box, Rating, Typography } from "@mui/material"; +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { getFee } from "../../background"; +import { MyContext, getBaseApiReact } from "../../App"; +import { CustomizedSnackbars } from "../Snackbar/Snackbar"; +import { StarFilledIcon } from "../../assets/svgs/StarFilled"; +import { StarEmptyIcon } from "../../assets/svgs/StarEmpty"; +import { AppInfoUserName } from "./Apps-styles"; +import { Spacer } from "../../common/Spacer"; + +export const AppRating = ({ app, myName, ratingCountPosition = "right" }) => { + const [value, setValue] = useState(0); + const { show } = useContext(MyContext); + const [hasPublishedRating, setHasPublishedRating] = useState( + null + ); + const [pollInfo, setPollInfo] = useState(null); + const [votesInfo, setVotesInfo] = useState(null); + const [openSnack, setOpenSnack] = useState(false); + const [infoSnack, setInfoSnack] = useState(null); + const hasCalledRef = useRef(false); + + const getRating = useCallback(async (name, service) => { + try { + hasCalledRef.current = true; + const pollName = `app-library-${service}-rating-${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")) { + setHasPublishedRating(false); + } else if (responseData?.pollName) { + setPollInfo(responseData); + setHasPublishedRating(true); + const urlVotes = `${getBaseApiReact()}/polls/votes/${pollName}`; + + const responseVotes = await fetch(urlVotes, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const responseDataVotes = await responseVotes.json(); + setVotesInfo(responseDataVotes); + const voteCount = responseDataVotes.voteCounts; + // Include initial value vote in the calculation + const ratingVotes = voteCount.filter( + (vote) => !vote.optionName.startsWith("initialValue-") + ); + const initialValueVote = voteCount.find((vote) => + vote.optionName.startsWith("initialValue-") + ); + if (initialValueVote) { + // Convert "initialValue-X" to just "X" and add it to the ratingVotes array + const initialRating = parseInt( + initialValueVote.optionName.split("-")[1], + 10 + ); + ratingVotes.push({ + optionName: initialRating.toString(), + voteCount: 1, + }); + } + + // Calculate the weighted average + let totalScore = 0; + let totalVotes = 0; + + ratingVotes.forEach((vote) => { + const rating = parseInt(vote.optionName, 10); // Extract rating value (1-5) + const count = vote.voteCount; + totalScore += rating * count; // Weighted score + totalVotes += count; // Total number of votes + }); + + // Calculate average rating (ensure no division by zero) + const averageRating = totalVotes > 0 ? totalScore / totalVotes : 0; + setValue(averageRating); + } + } catch (error) { + if (error?.message?.includes("POLL_NO_EXISTS")) { + setHasPublishedRating(false); + } + } + }, []); + useEffect(() => { + if (hasCalledRef.current) return; + if (!app) return; + getRating(app?.name, app?.service); + }, [getRating, app?.name]); + + const rateFunc = async (event, newValue) => { + try { + if (!myName) throw new Error("You need a name to rate."); + if (!app?.name) return; + const fee = await getFee("ARBITRARY"); + + await show({ + message: `Would you like to rate this app a rating of ${newValue}?`, + publishFee: fee.fee + " QORT", + }); + + if (hasPublishedRating === false) { + const pollName = `app-library-${app.service}-rating-${app.name}`; + const pollOptions = [`1, 2, 3, 4, 5, initialValue-${newValue}`]; + await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "CREATE_POLL", + type: "qortalRequest", + payload: { + pollName: pollName, + pollDescription: `Rating for ${app.service} ${app.name}`, + pollOptions: pollOptions, + pollOwnerAddress: myName, + }, + }, + (response) => { + if (response.error) { + rej(response?.message); + return; + } else { + res(response); + setInfoSnack({ + type: "success", + message: + "Successfully rated. Please wait a couple minutes for the network to propogate the changes.", + }); + setOpenSnack(true); + } + } + ); + }); + } else { + const pollName = `app-library-${app.service}-rating-${app.name}`; + const optionIndex = pollInfo?.pollOptions.findIndex( + (option) => +option.optionName === +newValue + ); + if (isNaN(optionIndex) || optionIndex === -1) + throw new Error("Cannot find rating option"); + await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "VOTE_ON_POLL", + type: "qortalRequest", + payload: { + pollName: pollName, + optionIndex, + }, + }, + (response) => { + if (response.error) { + rej(response?.message); + return; + } else { + res(response); + setInfoSnack({ + type: "success", + message: + "Successfully rated. Please wait a couple minutes for the network to propogate the changes.", + }); + setOpenSnack(true); + } + } + ); + }); + } + } catch (error) { + setInfoSnack({ + type: "error", + message: error.message || "An error occurred while trying to rate.", + }); + setOpenSnack(true); + } + }; + + return ( +
+ + {ratingCountPosition === "top" && ( + <> + + {(votesInfo?.totalVotes ?? 0) + + (votesInfo?.voteCounts?.length === 6 ? 1 : 0)}{" "} + {" RATINGS"} + + + {value?.toFixed(1)} + + + )} + + } + emptyIcon={} + sx={{ + display: "flex", + gap: "2px", + }} + /> + {ratingCountPosition === "right" && ( + + {(votesInfo?.totalVotes ?? 0) + + (votesInfo?.voteCounts?.length === 6 ? 1 : 0)} + + )} + + + +
+ ); +}; diff --git a/src/components/Apps/AppViewer.tsx b/src/components/Apps/AppViewer.tsx new file mode 100644 index 0000000..a2a5225 --- /dev/null +++ b/src/components/Apps/AppViewer.tsx @@ -0,0 +1,149 @@ +import React, { useContext, useEffect, useMemo, useState } from "react"; + +import { Avatar, Box, } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import { MyContext, getBaseApiReact, isMobile } from "../../App"; + +import { executeEvent, subscribeToEvent, unsubscribeFromEvent } from "../../utils/events"; +import { useFrame } from "react-frame-component"; +import { useQortalMessageListener } from "./useQortalMessageListener"; + + + + +export const AppViewer = React.forwardRef(({ app , hide}, iframeRef) => { + const { rootHeight } = useContext(MyContext); + // const iframeRef = useRef(null); + const { document, window: frameWindow } = useFrame(); + const {path, history, changeCurrentIndex} = useQortalMessageListener(frameWindow, iframeRef, app?.tabId) + const [url, setUrl] = useState('') + console.log('historyreact', history) + + useEffect(()=> { + setUrl(`${getBaseApiReact()}/render/${app?.service}/${app?.name}${app?.path != null ? `/${app?.path}` : ''}?theme=dark&identifier=${(app?.identifier != null && app?.identifier != 'null') ? app?.identifier : ''}`) + }, [app?.service, app?.name, app?.identifier, app?.path]) + const defaultUrl = useMemo(()=> { + return url + }, [url]) + + + + const refreshAppFunc = (e) => { + const {tabId} = e.detail + if(tabId === app?.tabId){ + const constructUrl = `${getBaseApiReact()}/render/${app?.service}/${app?.name}${path != null ? path : ''}?theme=dark&identifier=${app?.identifier != null ? app?.identifier : ''}&time=${new Date().getMilliseconds()}` + setUrl(constructUrl) + } + }; + + useEffect(() => { + subscribeToEvent("refreshApp", refreshAppFunc); + + return () => { + unsubscribeFromEvent("refreshApp", refreshAppFunc); + }; + }, [app, path]); + + // Function to navigate back in iframe + const navigateBackInIframe = async () => { + if (iframeRef.current && iframeRef.current.contentWindow && history?.currentIndex > 0) { + // Calculate the previous index and path + const previousPageIndex = history.currentIndex - 1; + const previousPath = history.customQDNHistoryPaths[previousPageIndex]; + + // Signal non-manual navigation + iframeRef.current.contentWindow.postMessage( + { action: 'PERFORMING_NON_MANUAL' }, '*' + ); + console.log('previousPageIndex', previousPageIndex) + // Update the current index locally + changeCurrentIndex(previousPageIndex); + + // Create a navigation promise with a 200ms timeout + const navigationPromise = new Promise((resolve, reject) => { + function handleNavigationSuccess(event) { + console.log('listeninghandlenav', event) + if (event.data?.action === 'NAVIGATION_SUCCESS' && event.data.path === previousPath) { + frameWindow.removeEventListener('message', handleNavigationSuccess); + resolve(); + } + } + + frameWindow.addEventListener('message', handleNavigationSuccess); + + // Timeout after 200ms if no response + setTimeout(() => { + window.removeEventListener('message', handleNavigationSuccess); + reject(new Error("Navigation timeout")); + }, 200); + + // Send the navigation command after setting up the listener and timeout + iframeRef.current.contentWindow.postMessage( + { action: 'NAVIGATE_TO_PATH', path: previousPath, requestedHandler: 'UI' }, '*' + ); + }); + + // Execute navigation promise and handle timeout fallback + try { + await navigationPromise; + console.log('Navigation succeeded within 200ms.'); + } catch (error) { + iframeRef.current.contentWindow.postMessage( + { action: 'PERFORMING_NON_MANUAL' }, '*' + ); + setUrl(`${getBaseApiReact()}/render/${app?.service}/${app?.name}${app?.previousPath != null ? previousPath : ''}?theme=dark&identifier=${(app?.identifier != null && app?.identifier != 'null') ? app?.identifier : ''}&time=${new Date().getMilliseconds()}&isManualNavigation=false`) + // iframeRef.current.contentWindow.location.href = previousPath; // Fallback URL update + } + } else { + console.log('Iframe not accessible or does not have a content window.'); + } +}; + + const navigateBackAppFunc = (e) => { + + navigateBackInIframe() + }; + + useEffect(() => { + if(!app?.tabId) return + subscribeToEvent(`navigateBackApp-${app?.tabId}`, navigateBackAppFunc); + + return () => { + unsubscribeFromEvent(`navigateBackApp-${app?.tabId}`, navigateBackAppFunc); + }; + }, [app, history]); + + + // Function to navigate back in iframe + const navigateForwardInIframe = async () => { + + + if (iframeRef.current && iframeRef.current.contentWindow) { + console.log('iframeRef.contentWindow', iframeRef.current.contentWindow); + iframeRef.current.contentWindow.postMessage( + { action: 'NAVIGATE_FORWARD'}, + '*' + ); + } else { + console.log('Iframe not accessible or does not have a content window.'); + } +}; + + + return ( + + + + + + ); +}); diff --git a/src/components/Apps/AppViewerContainer.tsx b/src/components/Apps/AppViewerContainer.tsx new file mode 100644 index 0000000..51bc0ff --- /dev/null +++ b/src/components/Apps/AppViewerContainer.tsx @@ -0,0 +1,50 @@ +import React, { useContext, } from 'react'; +import { AppViewer } from './AppViewer'; +import Frame from 'react-frame-component'; +import { MyContext, isMobile } from '../../App'; + +const AppViewerContainer = React.forwardRef(({ app, isSelected, hide }, ref) => { + const { rootHeight } = useContext(MyContext); + + + return ( + + + + } + style={{ + display: (!isSelected || hide) && 'none', + height: !isMobile ? '100vh' : `calc(${rootHeight} - 60px - 45px)`, + border: 'none', + width: '100%', + overflow: 'hidden', + }} + > + + + ); +}); + +export default AppViewerContainer; diff --git a/src/components/Apps/Apps-styles.tsx b/src/components/Apps/Apps-styles.tsx new file mode 100644 index 0000000..c6cb485 --- /dev/null +++ b/src/components/Apps/Apps-styles.tsx @@ -0,0 +1,311 @@ +import { + AppBar, + Button, + Toolbar, + Typography, + Box, + TextField, + InputLabel, + ButtonBase, + } from "@mui/material"; + import { styled } from "@mui/system"; + + export const AppsParent = styled(Box)(({ theme }) => ({ + display: "flex", + width: "100%", + flexDirection: "column", + height: "100%", + alignItems: "center", + overflow: 'auto', + // For WebKit-based browsers (Chrome, Safari, etc.) + "::-webkit-scrollbar": { + width: "0px", // Set the width to 0 to hide the scrollbar + height: "0px", // Set the height to 0 for horizontal scrollbar + }, + + // For Firefox + scrollbarWidth: "none", // Hides the scrollbar in Firefox + + // Optional for better cross-browser consistency + "-ms-overflow-style": "none" // Hides scrollbar in IE and Edge + })); + export const AppsContainer = styled(Box)(({ theme }) => ({ + display: "flex", + width: "90%", + justifyContent: 'space-evenly', + gap: '24px', + flexWrap: 'wrap', + alignItems: 'flex-start', + alignSelf: 'center' + + })); + export const AppsLibraryContainer = styled(Box)(({ theme }) => ({ + display: "flex", + width: "100%", + flexDirection: 'column', + justifyContent: 'flex-start', + alignItems: 'center', + })); + export const AppsWidthLimiter = styled(Box)(({ theme }) => ({ + display: "flex", + width: "90%", + flexDirection: 'column', + justifyContent: 'flex-start', + alignItems: 'flex-start', + })); + export const AppsSearchContainer = styled(Box)(({ theme }) => ({ + display: "flex", + width: "90%", + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: '#434343', + borderRadius: '8px', + padding: '0px 10px', + height: '36px' + })); + export const AppsSearchLeft = styled(Box)(({ theme }) => ({ + display: "flex", + width: "90%", + justifyContent: 'flex-start', + alignItems: 'center', + gap: '10px', + flexGrow: 1, + flexShrink: 0 + })); + export const AppsSearchRight = styled(Box)(({ theme }) => ({ + display: "flex", + width: "90%", + justifyContent: 'flex-end', + alignItems: 'center', + flexShrink: 1 + })); + export const AppCircleContainer = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: '5px', + alignItems: 'center', + width: '100%' + })); + export const Add = styled(Typography)(({ theme }) => ({ + fontSize: '36px', + fontWeight: 500, + lineHeight: '43.57px', + textAlign: 'left' + + })); + export const AppCircleLabel = styled(Typography)(({ theme }) => ({ + fontSize: '12px', + fontWeight: 500, + lineHeight: 1.2, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + width: '100%' + })); + export const AppLibrarySubTitle = styled(Typography)(({ theme }) => ({ + fontSize: '16px', + fontWeight: 500, + lineHeight: 1.2, + })); + export const AppCircle = styled(Box)(({ theme }) => ({ + display: "flex", + width: "60px", + flexDirection: "column", + height: "60px", + alignItems: 'center', + justifyContent: 'center', + borderRadius: '50%', + backgroundColor: "var(--apps-circle)", + border: '1px solid #FFFFFF' + })); + + export const AppInfoSnippetContainer = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: 'space-between', + alignItems: 'center', + width: '100%' + })); + + export const AppInfoSnippetLeft = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: 'flex-start', + alignItems: 'center', + gap: '12px' + })); + export const AppInfoSnippetRight = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: 'flex-end', + alignItems: 'center', + })); + + export const AppDownloadButton = styled(ButtonBase)(({ theme }) => ({ + backgroundColor: "#247C0E", + width: '101px', + height: '29px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: '25px', + alignSelf: 'center' + })); + + export const AppDownloadButtonText = styled(Typography)(({ theme }) => ({ + fontSize: '14px', + fontWeight: 500, + lineHeight: 1.2, + })); + + export const AppPublishTagsContainer = styled(Box)(({theme})=> ({ + gap: '10px', + flexWrap: 'wrap', + justifyContent: 'flex-start', + width: '100%', + display: 'flex' + })) + + + export const AppInfoSnippetMiddle = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + justifyContent: 'center', + alignItems: 'flex-start', + })); + + export const AppInfoAppName = styled(Typography)(({ theme }) => ({ + fontSize: '16px', + fontWeight: 500, + lineHeight: 1.2, + textAlign: 'start' + })); + export const AppInfoUserName = styled(Typography)(({ theme }) => ({ + fontSize: '13px', + fontWeight: 400, + lineHeight: 1.2, + color: '#8D8F93', + textAlign: 'start' + })); + + + export const AppsNavBarParent = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', + height: '60px', + backgroundColor: '#1F2023', + padding: '0px 10px', + position: "fixed", + bottom: 0, + zIndex: 1, + })); + + export const AppsNavBarLeft = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: 'flex-start', + alignItems: 'center', + flexGrow: 1 + })); + export const AppsNavBarRight = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: 'flex-end', + alignItems: 'center', + })); + + export const TabParent = styled(Box)(({ theme }) => ({ + height: '36px', + width: '36px', + backgroundColor: '#434343', + position: 'relative', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center' + })); + + export const PublishQAppCTAParent = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', + backgroundColor: '#181C23' + })); + + export const PublishQAppCTALeft = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: 'flex-start', + alignItems: 'center', + })); + export const PublishQAppCTARight = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: 'flex-end', + alignItems: 'center', + })); + + export const PublishQAppCTAButton = styled(ButtonBase)(({ theme }) => ({ + width: '101px', + height: '29px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: '25px', + border: '1px solid #FFFFFF' + })); + export const PublishQAppDotsBG = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: 'center', + alignItems: 'center', + width: '60px', + height: '60px', + backgroundColor: '#4BBCFE' + })); + + export const PublishQAppInfo = styled(Typography)(({ theme }) => ({ + fontSize: '10px', + fontWeight: 400, + lineHeight: 1.2, + fontStyle: 'italic' + })); + + export const PublishQAppChoseFile = styled(ButtonBase)(({ theme }) => ({ + width: '101px', + height: '30px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: '5px', + backgroundColor: '#0091E1', + color: 'white', + fontWeight: 600, + fontSize: '10px' + })); + + + export const AppsCategoryInfo = styled(Box)(({ theme }) => ({ + display: "flex", + alignItems: 'center', + width: '100%', + })); + + export const AppsCategoryInfoSub = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: 'column', + })); + export const AppsCategoryInfoLabel = styled(Typography)(({ theme }) => ({ + fontSize: '12px', + fontWeight: 700, + lineHeight: 1.2, + color: '#8D8F93', + })); + export const AppsCategoryInfoValue = styled(Typography)(({ theme }) => ({ + fontSize: '12px', + fontWeight: 500, + lineHeight: 1.2, + color: '#8D8F93', + })); + export const AppsInfoDescription = styled(Typography)(({ theme }) => ({ + fontSize: '13px', + fontWeight: 300, + lineHeight: 1.2, + width: '90%', + textAlign: 'start' + })); \ No newline at end of file diff --git a/src/components/Apps/Apps.tsx b/src/components/Apps/Apps.tsx new file mode 100644 index 0000000..b444c22 --- /dev/null +++ b/src/components/Apps/Apps.tsx @@ -0,0 +1,326 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { AppsHome } from "./AppsHome"; +import { Spacer } from "../../common/Spacer"; +import { getBaseApiReact } from "../../App"; +import { AppInfo } from "./AppInfo"; +import { + executeEvent, + subscribeToEvent, + unsubscribeFromEvent, +} from "../../utils/events"; +import { AppsParent } from "./Apps-styles"; +import AppViewerContainer from "./AppViewerContainer"; +import ShortUniqueId from "short-unique-id"; +import { AppPublish } from "./AppPublish"; +import { AppsCategory } from "./AppsCategory"; +import { AppsLibrary } from "./AppsLibrary"; + +const uid = new ShortUniqueId({ length: 8 }); + +export const Apps = ({ mode, setMode, show , myName}) => { + const [availableQapps, setAvailableQapps] = useState([]); + const [selectedAppInfo, setSelectedAppInfo] = useState(null); + const [selectedCategory, setSelectedCategory] = useState(null) + const [tabs, setTabs] = useState([]); + const [selectedTab, setSelectedTab] = useState(null); + const [isNewTabWindow, setIsNewTabWindow] = useState(false); + const [categories, setCategories] = useState([]) + const iframeRefs = useRef({}); + + + const myApp = useMemo(()=> { + + return availableQapps.find((app)=> app.name === myName && app.service === 'APP') + }, [myName, availableQapps]) + const myWebsite = useMemo(()=> { + + return availableQapps.find((app)=> app.name === myName && app.service === 'WEBSITE') + }, [myName, availableQapps]) + + useEffect(() => { + setTimeout(() => { + executeEvent("setTabsToNav", { + data: { + tabs: tabs, + selectedTab: selectedTab, + isNewTabWindow: isNewTabWindow, + }, + }); + }, 100); + }, [show, tabs, selectedTab, isNewTabWindow]); + + const getCategories = React.useCallback(async () => { + try { + const url = `${getBaseApiReact()}/arbitrary/categories`; + + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + if (!response?.ok) return; + const responseData = await response.json(); + + setCategories(responseData); + + } catch (error) { + } finally { + // dispatch(setIsLoadingGlobal(false)) + } + }, []); + + const getQapps = React.useCallback(async () => { + try { + let apps = []; + let websites = []; + // dispatch(setIsLoadingGlobal(true)) + const url = `${getBaseApiReact()}/arbitrary/resources/search?service=APP&mode=ALL&limit=0&includestatus=true&includemetadata=true`; + + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + if (!response?.ok) return; + const responseData = await response.json(); + const urlWebsites = `${getBaseApiReact()}/arbitrary/resources/search?service=WEBSITE&mode=ALL&limit=0&includestatus=true&includemetadata=true`; + + const responseWebsites = await fetch(urlWebsites, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + if (!responseWebsites?.ok) return; + const responseDataWebsites = await responseWebsites.json(); + + apps = responseData; + websites = responseDataWebsites; + const combine = [...apps, ...websites]; + setAvailableQapps(combine); + } catch (error) { + } finally { + // dispatch(setIsLoadingGlobal(false)) + } + }, []); + useEffect(() => { + getQapps(); + getCategories() + }, [getQapps, getCategories]); + + const selectedAppInfoFunc = (e) => { + const data = e.detail?.data; + setSelectedAppInfo(data); + setMode("appInfo"); + }; + + useEffect(() => { + subscribeToEvent("selectedAppInfo", selectedAppInfoFunc); + + return () => { + unsubscribeFromEvent("selectedAppInfo", selectedAppInfoFunc); + }; + }, []); + + const selectedAppInfoCategoryFunc = (e) => { + const data = e.detail?.data; + setSelectedAppInfo(data); + setMode("appInfo-from-category"); + }; + + useEffect(() => { + subscribeToEvent("selectedAppInfoCategory", selectedAppInfoCategoryFunc); + + return () => { + unsubscribeFromEvent("selectedAppInfoCategory", selectedAppInfoCategoryFunc); + }; + }, []); + + + + const selectedCategoryFunc = (e) => { + const data = e.detail?.data; + setSelectedCategory(data); + setMode("category"); + }; + + useEffect(() => { + subscribeToEvent("selectedCategory", selectedCategoryFunc); + + return () => { + unsubscribeFromEvent("selectedCategory", selectedCategoryFunc); + }; + }, []); + + + const navigateBackFunc = (e) => { + if (['category', 'appInfo-from-category', 'appInfo', 'library', 'publish'].includes(mode)) { + // Handle the various modes as needed + if (mode === 'category') { + setMode('library'); + setSelectedCategory(null); + } else if (mode === 'appInfo-from-category') { + setMode('category'); + } else if (mode === 'appInfo') { + setMode('library'); + } else if (mode === 'library') { + if (isNewTabWindow) { + setMode('viewer'); + } else { + setMode('home'); + } + } else if (mode === 'publish') { + setMode('library'); + } + } else if(selectedTab?.tabId) { + executeEvent(`navigateBackApp-${selectedTab?.tabId}`, {}) + } + }; + + useEffect(() => { + subscribeToEvent("navigateBack", navigateBackFunc); + + return () => { + unsubscribeFromEvent("navigateBack", navigateBackFunc); + }; + }, [mode, selectedTab]); + + const addTabFunc = (e) => { + const data = e.detail?.data; + const newTab = { + ...data, + tabId: uid.rnd(), + }; + setTabs((prev) => [...prev, newTab]); + setSelectedTab(newTab); + setMode("viewer"); + + setIsNewTabWindow(false); + }; + + useEffect(() => { + subscribeToEvent("addTab", addTabFunc); + + return () => { + unsubscribeFromEvent("addTab", addTabFunc); + }; + }, [tabs]); + + const setSelectedTabFunc = (e) => { + const data = e.detail?.data; + + setSelectedTab(data); + setTimeout(() => { + executeEvent("setTabsToNav", { + data: { + tabs: tabs, + selectedTab: data, + isNewTabWindow: isNewTabWindow, + }, + }); + }, 100); + setIsNewTabWindow(false); + }; + + useEffect(() => { + subscribeToEvent("setSelectedTab", setSelectedTabFunc); + + return () => { + unsubscribeFromEvent("setSelectedTab", setSelectedTabFunc); + }; + }, [tabs, isNewTabWindow]); + + const removeTabFunc = (e) => { + const data = e.detail?.data; + const copyTabs = [...tabs].filter((tab) => tab?.tabId !== data?.tabId); + if (copyTabs?.length === 0) { + setMode("home"); + } else { + setSelectedTab(copyTabs[0]); + } + setTabs(copyTabs); + setSelectedTab(copyTabs[0]); + setTimeout(() => { + executeEvent("setTabsToNav", { + data: { + tabs: copyTabs, + selectedTab: copyTabs[0], + }, + }); + }, 400); + }; + + useEffect(() => { + subscribeToEvent("removeTab", removeTabFunc); + + return () => { + unsubscribeFromEvent("removeTab", removeTabFunc); + }; + }, [tabs]); + + const setNewTabWindowFunc = (e) => { + setIsNewTabWindow(true); + setSelectedTab(null) + }; + + useEffect(() => { + subscribeToEvent("newTabWindow", setNewTabWindowFunc); + + return () => { + unsubscribeFromEvent("newTabWindow", setNewTabWindowFunc); + }; + }, [tabs]); + + + return ( + + {mode !== "viewer" && !selectedTab && } + {mode === "home" && ( + + )} + + + + {mode === "appInfo" && !selectedTab && } + {mode === "appInfo-from-category" && !selectedTab && } + + {mode === "publish" && !selectedTab && } + + {tabs.map((tab) => { + if (!iframeRefs.current[tab.tabId]) { + iframeRefs.current[tab.tabId] = React.createRef(); + } + return ( + + ); + })} + + {isNewTabWindow && mode === "viewer" && ( + <> + + + + )} + {mode !== "viewer" && !selectedTab && } + + ); +}; diff --git a/src/components/Apps/AppsCategory.tsx b/src/components/Apps/AppsCategory.tsx new file mode 100644 index 0000000..a999c95 --- /dev/null +++ b/src/components/Apps/AppsCategory.tsx @@ -0,0 +1,188 @@ +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + AppCircle, + AppCircleContainer, + AppCircleLabel, + AppLibrarySubTitle, + AppsContainer, + AppsLibraryContainer, + AppsParent, + AppsSearchContainer, + AppsSearchLeft, + AppsSearchRight, + AppsWidthLimiter, + PublishQAppCTAButton, + PublishQAppCTALeft, + PublishQAppCTAParent, + PublishQAppCTARight, + PublishQAppDotsBG, +} from "./Apps-styles"; +import { Avatar, Box, ButtonBase, InputBase, styled } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import { MyContext, getBaseApiReact } from "../../App"; +import LogoSelected from "../../assets/svgs/LogoSelected.svg"; +import IconSearch from "../../assets/svgs/Search.svg"; +import IconClearInput from "../../assets/svgs/ClearInput.svg"; +import qappDevelopText from "../../assets/svgs/qappDevelopText.svg"; +import qappDots from "../../assets/svgs/qappDots.svg"; + +import { Spacer } from "../../common/Spacer"; +import { AppInfoSnippet } from "./AppInfoSnippet"; +import { Virtuoso } from "react-virtuoso"; +import { executeEvent } from "../../utils/events"; +const officialAppList = [ + "q-tube", + "q-blog", + "q-share", + "q-support", + "q-mail", + "qombo", + "q-fund", + "q-shop", +]; + +const ScrollerStyled = styled('div')({ + // Hide scrollbar for WebKit browsers (Chrome, Safari) + "::-webkit-scrollbar": { + width: "0px", + height: "0px", + }, + + // Hide scrollbar for Firefox + scrollbarWidth: "none", + + // Hide scrollbar for IE and older Edge + "-ms-overflow-style": "none", + }); + + const StyledVirtuosoContainer = styled('div')({ + position: 'relative', + width: '100%', + display: 'flex', + flexDirection: 'column', + + // Hide scrollbar for WebKit browsers (Chrome, Safari) + "::-webkit-scrollbar": { + width: "0px", + height: "0px", + }, + + // Hide scrollbar for Firefox + scrollbarWidth: "none", + + // Hide scrollbar for IE and older Edge + "-ms-overflow-style": "none", + }); + +export const AppsCategory = ({ availableQapps, myName, category, isShow }) => { + const [searchValue, setSearchValue] = useState(""); + const virtuosoRef = useRef(); + const { rootHeight } = useContext(MyContext); + + + + const categoryList = useMemo(() => { + return availableQapps.filter( + (app) => + app?.metadata?.category === category?.id + ); + }, [availableQapps, category]); + + const [debouncedValue, setDebouncedValue] = useState(""); // Debounced value + + // 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 + + // Example: Perform search or other actions based on debouncedValue + + const searchedList = useMemo(() => { + if (!debouncedValue) return categoryList + return categoryList.filter((app) => + app.name.toLowerCase().includes(debouncedValue.toLowerCase()) + ); + }, [debouncedValue, categoryList]); + + const rowRenderer = (index) => { + + let app = searchedList[index]; + return ; + }; + + + + return ( + + + + + + + setSearchValue(e.target.value)} + sx={{ ml: 1, flex: 1 }} + placeholder="Search for apps" + inputProps={{ + "aria-label": "Search for apps", + fontSize: "16px", + fontWeight: 400, + }} + /> + + + {searchValue && ( + { + setSearchValue(""); + }} + > + + + )} + + + + + + + {`Category: ${category?.name}`} + + + + + + + + + + + ); +}; diff --git a/src/components/Apps/AppsCategoryDesktop.tsx b/src/components/Apps/AppsCategoryDesktop.tsx new file mode 100644 index 0000000..91e818d --- /dev/null +++ b/src/components/Apps/AppsCategoryDesktop.tsx @@ -0,0 +1,223 @@ +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + AppCircle, + AppCircleContainer, + AppCircleLabel, + AppLibrarySubTitle, + AppsContainer, + AppsLibraryContainer, + AppsParent, + AppsSearchContainer, + AppsSearchLeft, + AppsSearchRight, + AppsWidthLimiter, + PublishQAppCTAButton, + PublishQAppCTALeft, + PublishQAppCTAParent, + PublishQAppCTARight, + PublishQAppDotsBG, +} from "./Apps-styles"; +import { Avatar, Box, ButtonBase, InputBase, styled } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import { MyContext, getBaseApiReact } from "../../App"; +import LogoSelected from "../../assets/svgs/LogoSelected.svg"; +import IconSearch from "../../assets/svgs/Search.svg"; +import IconClearInput from "../../assets/svgs/ClearInput.svg"; +import qappDevelopText from "../../assets/svgs/qappDevelopText.svg"; +import qappDots from "../../assets/svgs/qappDots.svg"; + +import { Spacer } from "../../common/Spacer"; +import { AppInfoSnippet } from "./AppInfoSnippet"; +import { Virtuoso } from "react-virtuoso"; +import { executeEvent } from "../../utils/events"; +import { AppsDesktopLibraryBody, AppsDesktopLibraryHeader } from "./AppsDesktop-styles"; +const officialAppList = [ + "q-tube", + "q-blog", + "q-share", + "q-support", + "q-mail", + "qombo", + "q-fund", + "q-shop", +]; + +const ScrollerStyled = styled("div")({ + // Hide scrollbar for WebKit browsers (Chrome, Safari) + "::-webkit-scrollbar": { + width: "0px", + height: "0px", + }, + + // Hide scrollbar for Firefox + scrollbarWidth: "none", + + // Hide scrollbar for IE and older Edge + "-ms-overflow-style": "none", +}); + +const StyledVirtuosoContainer = styled("div")({ + position: "relative", + width: "100%", + display: "flex", + flexDirection: "column", + + // Hide scrollbar for WebKit browsers (Chrome, Safari) + "::-webkit-scrollbar": { + width: "0px", + height: "0px", + }, + + // Hide scrollbar for Firefox + scrollbarWidth: "none", + + // Hide scrollbar for IE and older Edge + "-ms-overflow-style": "none", +}); + +export const AppsCategoryDesktop = ({ + availableQapps, + myName, + category, + isShow, +}) => { + const [searchValue, setSearchValue] = useState(""); + const virtuosoRef = useRef(); + const { rootHeight } = useContext(MyContext); + + const categoryList = useMemo(() => { + return availableQapps.filter( + (app) => app?.metadata?.category === category?.id + ); + }, [availableQapps, category]); + + const [debouncedValue, setDebouncedValue] = useState(""); // Debounced value + + // 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 + + // Example: Perform search or other actions based on debouncedValue + + const searchedList = useMemo(() => { + if (!debouncedValue) return categoryList; + return categoryList.filter((app) => + app.name.toLowerCase().includes(debouncedValue.toLowerCase()) + ); + }, [debouncedValue, categoryList]); + + const rowRenderer = (index) => { + let app = searchedList[index]; + return ( + + ); + }; + + return ( + + + + + + + setSearchValue(e.target.value)} + sx={{ ml: 1, flex: 1 }} + placeholder="Search for apps" + inputProps={{ + "aria-label": "Search for apps", + fontSize: "16px", + fontWeight: 400, + }} + /> + + + {searchValue && ( + { + setSearchValue(""); + }} + > + + + )} + + + + + + + + {`Category: ${category?.name}`} + + + + + + + + + + + ); +}; diff --git a/src/components/Apps/AppsDesktop-styles.tsx b/src/components/Apps/AppsDesktop-styles.tsx new file mode 100644 index 0000000..26cb567 --- /dev/null +++ b/src/components/Apps/AppsDesktop-styles.tsx @@ -0,0 +1,24 @@ +import { + AppBar, + Button, + Toolbar, + Typography, + Box, + TextField, + InputLabel, + ButtonBase, + } from "@mui/material"; + import { styled } from "@mui/system"; + + export const AppsDesktopLibraryHeader = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: 'column', + flexShrink: 0, + width: '100%' + })); + export const AppsDesktopLibraryBody = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: 'column', + flexGrow: 1, + width: '100%' + })); \ No newline at end of file diff --git a/src/components/Apps/AppsDesktop.tsx b/src/components/Apps/AppsDesktop.tsx new file mode 100644 index 0000000..7065782 --- /dev/null +++ b/src/components/Apps/AppsDesktop.tsx @@ -0,0 +1,427 @@ +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { AppsHomeDesktop } from "./AppsHomeDesktop"; +import { Spacer } from "../../common/Spacer"; +import { MyContext, getBaseApiReact } from "../../App"; +import { AppInfo } from "./AppInfo"; +import { + executeEvent, + subscribeToEvent, + unsubscribeFromEvent, +} from "../../utils/events"; +import { AppsParent } from "./Apps-styles"; +import AppViewerContainer from "./AppViewerContainer"; +import ShortUniqueId from "short-unique-id"; +import { AppPublish } from "./AppPublish"; +import { AppsLibraryDesktop } from "./AppsLibraryDesktop"; +import { AppsCategoryDesktop } from "./AppsCategoryDesktop"; +import { AppsNavBarDesktop } from "./AppsNavBarDesktop"; +import { Box, ButtonBase } from "@mui/material"; +import { HomeIcon } from "../../assets/Icons/HomeIcon"; +import { MessagingIcon } from "../../assets/Icons/MessagingIcon"; +import { Save } from "../Save/Save"; +import { HubsIcon } from "../../assets/Icons/HubsIcon"; + +const uid = new ShortUniqueId({ length: 8 }); + +export const AppsDesktop = ({ mode, setMode, show , myName, goToHome, setDesktopSideView, hasUnreadDirects, isDirects, isGroups, hasUnreadGroups, toggleSideViewGroups, toggleSideViewDirects}) => { + const [availableQapps, setAvailableQapps] = useState([]); + const [selectedAppInfo, setSelectedAppInfo] = useState(null); + const [selectedCategory, setSelectedCategory] = useState(null) + const [tabs, setTabs] = useState([]); + const [selectedTab, setSelectedTab] = useState(null); + const [isNewTabWindow, setIsNewTabWindow] = useState(false); + const [categories, setCategories] = useState([]) + const iframeRefs = useRef({}); + const myApp = useMemo(()=> { + + return availableQapps.find((app)=> app.name === myName && app.service === 'APP') + }, [myName, availableQapps]) + const myWebsite = useMemo(()=> { + + return availableQapps.find((app)=> app.name === myName && app.service === 'WEBSITE') + }, [myName, availableQapps]) + + useEffect(() => { + setTimeout(() => { + executeEvent("setTabsToNav", { + data: { + tabs: tabs, + selectedTab: selectedTab, + isNewTabWindow: isNewTabWindow, + }, + }); + }, 100); + }, [show, tabs, selectedTab, isNewTabWindow]); + + const getCategories = React.useCallback(async () => { + try { + const url = `${getBaseApiReact()}/arbitrary/categories`; + + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + if (!response?.ok) return; + const responseData = await response.json(); + + setCategories(responseData); + + } catch (error) { + } finally { + // dispatch(setIsLoadingGlobal(false)) + } + }, []); + + const getQapps = React.useCallback(async () => { + try { + let apps = []; + let websites = []; + // dispatch(setIsLoadingGlobal(true)) + const url = `${getBaseApiReact()}/arbitrary/resources/search?service=APP&mode=ALL&limit=0&includestatus=true&includemetadata=true`; + + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + if (!response?.ok) return; + const responseData = await response.json(); + const urlWebsites = `${getBaseApiReact()}/arbitrary/resources/search?service=WEBSITE&mode=ALL&limit=0&includestatus=true&includemetadata=true`; + + const responseWebsites = await fetch(urlWebsites, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + if (!responseWebsites?.ok) return; + const responseDataWebsites = await responseWebsites.json(); + + apps = responseData; + websites = responseDataWebsites; + const combine = [...apps, ...websites]; + setAvailableQapps(combine); + } catch (error) { + } finally { + // dispatch(setIsLoadingGlobal(false)) + } + }, []); + useEffect(() => { + getQapps(); + getCategories() + }, [getQapps, getCategories]); + + const selectedAppInfoFunc = (e) => { + const data = e.detail?.data; + setSelectedAppInfo(data); + setMode("appInfo"); + }; + + useEffect(() => { + subscribeToEvent("selectedAppInfo", selectedAppInfoFunc); + + return () => { + unsubscribeFromEvent("selectedAppInfo", selectedAppInfoFunc); + }; + }, []); + + const selectedAppInfoCategoryFunc = (e) => { + const data = e.detail?.data; + setSelectedAppInfo(data); + setMode("appInfo-from-category"); + }; + + useEffect(() => { + subscribeToEvent("selectedAppInfoCategory", selectedAppInfoCategoryFunc); + + return () => { + unsubscribeFromEvent("selectedAppInfoCategory", selectedAppInfoCategoryFunc); + }; + }, []); + + + + const selectedCategoryFunc = (e) => { + const data = e.detail?.data; + setSelectedCategory(data); + setMode("category"); + }; + + useEffect(() => { + subscribeToEvent("selectedCategory", selectedCategoryFunc); + + return () => { + unsubscribeFromEvent("selectedCategory", selectedCategoryFunc); + }; + }, []); + + + + + + + const navigateBackFunc = (e) => { + if (['category', 'appInfo-from-category', 'appInfo', 'library', 'publish'].includes(mode)) { + // Handle the various modes as needed + if (mode === 'category') { + setMode('library'); + setSelectedCategory(null); + } else if (mode === 'appInfo-from-category') { + setMode('category'); + } else if (mode === 'appInfo') { + setMode('library'); + } else if (mode === 'library') { + if (isNewTabWindow) { + setMode('viewer'); + } else { + setMode('home'); + } + } else if (mode === 'publish') { + setMode('library'); + } + } else if(selectedTab?.tabId) { + executeEvent(`navigateBackApp-${selectedTab?.tabId}`, {}) + } + }; + + + useEffect(() => { + subscribeToEvent("navigateBack", navigateBackFunc); + + return () => { + unsubscribeFromEvent("navigateBack", navigateBackFunc); + }; + }, [mode, selectedTab]); + + const addTabFunc = (e) => { + const data = e.detail?.data; + const newTab = { + ...data, + tabId: uid.rnd(), + }; + setTabs((prev) => [...prev, newTab]); + setSelectedTab(newTab); + setMode("viewer"); + + setIsNewTabWindow(false); + }; + + + + useEffect(() => { + subscribeToEvent("addTab", addTabFunc); + + return () => { + unsubscribeFromEvent("addTab", addTabFunc); + }; + }, [tabs]); + const setSelectedTabFunc = (e) => { + const data = e.detail?.data; + + setSelectedTab(data); + setTimeout(() => { + executeEvent("setTabsToNav", { + data: { + tabs: tabs, + selectedTab: data, + isNewTabWindow: isNewTabWindow, + }, + }); + }, 100); + setIsNewTabWindow(false); + }; + + + useEffect(() => { + subscribeToEvent("setSelectedTab", setSelectedTabFunc); + + return () => { + unsubscribeFromEvent("setSelectedTab", setSelectedTabFunc); + }; + }, [tabs, isNewTabWindow]); + + const removeTabFunc = (e) => { + const data = e.detail?.data; + const copyTabs = [...tabs].filter((tab) => tab?.tabId !== data?.tabId); + if (copyTabs?.length === 0) { + setMode("home"); + } else { + setSelectedTab(copyTabs[0]); + } + setTabs(copyTabs); + setSelectedTab(copyTabs[0]); + setTimeout(() => { + executeEvent("setTabsToNav", { + data: { + tabs: copyTabs, + selectedTab: copyTabs[0], + }, + }); + }, 400); + }; + + useEffect(() => { + subscribeToEvent("removeTab", removeTabFunc); + + return () => { + unsubscribeFromEvent("removeTab", removeTabFunc); + }; + }, [tabs]); + + const setNewTabWindowFunc = (e) => { + setIsNewTabWindow(true); + setSelectedTab(null) + }; + + useEffect(() => { + subscribeToEvent("newTabWindow", setNewTabWindowFunc); + + return () => { + unsubscribeFromEvent("newTabWindow", setNewTabWindowFunc); + }; + }, [tabs]); + + + return ( + + + + { + goToHome(); + + }} + > + + + + + { + setDesktopSideView("directs"); + toggleSideViewDirects() + }} + > + + + + + { + setDesktopSideView("groups"); + toggleSideViewGroups() + }} + > + + + + + {mode !== 'home' && ( + + + )} + + + + + {mode === "home" && ( + + + + + + )} + + + + {mode === "appInfo" && !selectedTab && } + {mode === "appInfo-from-category" && !selectedTab && } + + {mode === "publish" && !selectedTab && } + {tabs.map((tab) => { + if (!iframeRefs.current[tab.tabId]) { + iframeRefs.current[tab.tabId] = React.createRef(); + } + return ( + + ); + })} + + {isNewTabWindow && mode === "viewer" && ( + <> + + + + + + + )} + + ); +}; diff --git a/src/components/Apps/AppsHome.tsx b/src/components/Apps/AppsHome.tsx new file mode 100644 index 0000000..81fc9b8 --- /dev/null +++ b/src/components/Apps/AppsHome.tsx @@ -0,0 +1,57 @@ +import React, { useMemo, useState } from "react"; +import { + AppCircle, + AppCircleContainer, + AppCircleLabel, + AppLibrarySubTitle, + AppsContainer, + AppsParent, +} from "./Apps-styles"; +import { Avatar, ButtonBase } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import { getBaseApiReact, isMobile } from "../../App"; +import LogoSelected from "../../assets/svgs/LogoSelected.svg"; +import { executeEvent } from "../../utils/events"; +import { SortablePinnedApps } from "./SortablePinnedApps"; +import { Spacer } from "../../common/Spacer"; + +export const AppsHome = ({ setMode, myApp, myWebsite, availableQapps }) => { + return ( + <> + + + Apps Dashboard + + + + + + + { + setMode("library"); + }} + > + + + + + + Library + + + + + + + + ); +}; diff --git a/src/components/Apps/AppsHomeDesktop.tsx b/src/components/Apps/AppsHomeDesktop.tsx new file mode 100644 index 0000000..e7346ff --- /dev/null +++ b/src/components/Apps/AppsHomeDesktop.tsx @@ -0,0 +1,73 @@ +import React, { useMemo, useState } from "react"; +import { + AppCircle, + AppCircleContainer, + AppCircleLabel, + AppLibrarySubTitle, + AppsContainer, + AppsParent, +} from "./Apps-styles"; +import { Avatar, ButtonBase } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import { getBaseApiReact, isMobile } from "../../App"; +import LogoSelected from "../../assets/svgs/LogoSelected.svg"; +import { executeEvent } from "../../utils/events"; +import { SortablePinnedApps } from "./SortablePinnedApps"; +import { Spacer } from "../../common/Spacer"; + +export const AppsHomeDesktop = ({ + setMode, + myApp, + myWebsite, + availableQapps, +}) => { + return ( + <> + + + Apps Dashboard + + + + + { + setMode("library"); + }} + > + + + + + + Library + + + + + + + ); +}; diff --git a/src/components/Apps/AppsLibrary.tsx b/src/components/Apps/AppsLibrary.tsx new file mode 100644 index 0000000..cfe5b3f --- /dev/null +++ b/src/components/Apps/AppsLibrary.tsx @@ -0,0 +1,322 @@ +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + AppCircle, + AppCircleContainer, + AppCircleLabel, + AppLibrarySubTitle, + AppsContainer, + AppsLibraryContainer, + AppsParent, + AppsSearchContainer, + AppsSearchLeft, + AppsSearchRight, + AppsWidthLimiter, + PublishQAppCTAButton, + PublishQAppCTALeft, + PublishQAppCTAParent, + PublishQAppCTARight, + PublishQAppDotsBG, +} from "./Apps-styles"; +import { Avatar, Box, ButtonBase, InputBase, styled } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import { MyContext, getBaseApiReact } from "../../App"; +import LogoSelected from "../../assets/svgs/LogoSelected.svg"; +import IconSearch from "../../assets/svgs/Search.svg"; +import IconClearInput from "../../assets/svgs/ClearInput.svg"; +import qappDevelopText from "../../assets/svgs/qappDevelopText.svg"; +import qappDots from "../../assets/svgs/qappDots.svg"; +import ReturnSVG from '../../assets/svgs/Return.svg' + +import { Spacer } from "../../common/Spacer"; +import { AppInfoSnippet } from "./AppInfoSnippet"; +import { Virtuoso } from "react-virtuoso"; +import { executeEvent } from "../../utils/events"; +import { ComposeP, MailIconImg, ShowMessageReturnButton } from "../Group/Forum/Mail-styles"; +const officialAppList = [ + "q-tube", + "q-blog", + "q-share", + "q-support", + "q-mail", + "qombo", + "q-fund", + "q-shop", +]; + +const ScrollerStyled = styled('div')({ + // Hide scrollbar for WebKit browsers (Chrome, Safari) + "::-webkit-scrollbar": { + width: "0px", + height: "0px", + }, + + // Hide scrollbar for Firefox + scrollbarWidth: "none", + + // Hide scrollbar for IE and older Edge + "-ms-overflow-style": "none", + }); + + const StyledVirtuosoContainer = styled('div')({ + position: 'relative', + width: '100%', + display: 'flex', + flexDirection: 'column', + + // Hide scrollbar for WebKit browsers (Chrome, Safari) + "::-webkit-scrollbar": { + width: "0px", + height: "0px", + }, + + // Hide scrollbar for Firefox + scrollbarWidth: "none", + + // Hide scrollbar for IE and older Edge + "-ms-overflow-style": "none", + }); + +export const AppsLibrary = ({ availableQapps, setMode, myName, hasPublishApp, isShow, categories={categories} }) => { + const [searchValue, setSearchValue] = useState(""); + const virtuosoRef = useRef(); + const { rootHeight } = useContext(MyContext); + + const officialApps = useMemo(() => { + return availableQapps.filter( + (app) => + app.service === "APP" && + officialAppList.includes(app?.name?.toLowerCase()) + ); + }, [availableQapps]); + + const [debouncedValue, setDebouncedValue] = useState(""); // Debounced value + + // 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 + + // Example: Perform search or other actions based on debouncedValue + + const searchedList = useMemo(() => { + if (!debouncedValue) return []; + return availableQapps.filter((app) => + app.name.toLowerCase().includes(debouncedValue.toLowerCase()) + ); + }, [debouncedValue]); + + const rowRenderer = (index) => { + + let app = searchedList[index]; + return ; + }; + + + + return ( + + + + + + + setSearchValue(e.target.value)} + sx={{ ml: 1, flex: 1 }} + placeholder="Search for apps" + inputProps={{ + "aria-label": "Search for apps", + fontSize: "16px", + fontWeight: 400, + }} + /> + + + {searchValue && ( + { + setSearchValue(""); + }} + > + + + )} + + + + + + { + executeEvent("navigateBack", {}); + + }}> + + Return to Apps Dashboard + + + {searchedList?.length > 0 ? ( + + + + + + ) : ( + <> + + Official Apps + + + {officialApps?.map((qapp) => { + return ( + { + // executeEvent("addTab", { + // data: qapp + // }) + executeEvent("selectedAppInfo", { + data: qapp, + }); + }} + > + + + + center-icon + + + + {qapp?.metadata?.title || qapp?.name} + + + + ); + })} + + + {hasPublishApp ? 'Update Apps!' : 'Create Apps!'} + + + + + + + + + + + + { + setMode('publish') + }}> + + {hasPublishApp ? 'Update' : 'Publish'} + + + + + + + + Categories + + + {categories?.map((category)=> { + return ( + { + executeEvent('selectedCategory', { + data: category + }) + }}> + + {category?.name} + + + ) + })} + + + + )} + + ); +}; diff --git a/src/components/Apps/AppsLibraryDesktop.tsx b/src/components/Apps/AppsLibraryDesktop.tsx new file mode 100644 index 0000000..20a631a --- /dev/null +++ b/src/components/Apps/AppsLibraryDesktop.tsx @@ -0,0 +1,423 @@ +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + AppCircle, + AppCircleContainer, + AppCircleLabel, + AppLibrarySubTitle, + AppsContainer, + AppsLibraryContainer, + AppsParent, + AppsSearchContainer, + AppsSearchLeft, + AppsSearchRight, + AppsWidthLimiter, + PublishQAppCTAButton, + PublishQAppCTALeft, + PublishQAppCTAParent, + PublishQAppCTARight, + PublishQAppDotsBG, +} from "./Apps-styles"; +import { Avatar, Box, ButtonBase, InputBase, styled } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import { MyContext, getBaseApiReact } from "../../App"; +import LogoSelected from "../../assets/svgs/LogoSelected.svg"; +import IconSearch from "../../assets/svgs/Search.svg"; +import IconClearInput from "../../assets/svgs/ClearInput.svg"; +import qappDevelopText from "../../assets/svgs/qappDevelopText.svg"; +import qappLibraryText from "../../assets/svgs/qappLibraryText.svg"; + +import qappDots from "../../assets/svgs/qappDots.svg"; + +import { Spacer } from "../../common/Spacer"; +import { AppInfoSnippet } from "./AppInfoSnippet"; +import { Virtuoso } from "react-virtuoso"; +import { executeEvent } from "../../utils/events"; +import { + AppsDesktopLibraryBody, + AppsDesktopLibraryHeader, +} from "./AppsDesktop-styles"; +import { AppsNavBarDesktop } from "./AppsNavBarDesktop"; +import ReturnSVG from '../../assets/svgs/Return.svg' +import { ComposeP, MailIconImg, ShowMessageReturnButton } from "../Group/Forum/Mail-styles"; +const officialAppList = [ + "q-tube", + "q-blog", + "q-share", + "q-support", + "q-mail", + "qombo", + "q-fund", + "q-shop", +]; + +const ScrollerStyled = styled("div")({ + // Hide scrollbar for WebKit browsers (Chrome, Safari) + "::-webkit-scrollbar": { + width: "0px", + height: "0px", + }, + + // Hide scrollbar for Firefox + scrollbarWidth: "none", + + // Hide scrollbar for IE and older Edge + "-ms-overflow-style": "none", +}); + +const StyledVirtuosoContainer = styled("div")({ + position: "relative", + width: "100%", + display: "flex", + flexDirection: "column", + + // Hide scrollbar for WebKit browsers (Chrome, Safari) + "::-webkit-scrollbar": { + width: "0px", + height: "0px", + }, + + // Hide scrollbar for Firefox + scrollbarWidth: "none", + + // Hide scrollbar for IE and older Edge + "-ms-overflow-style": "none", +}); + +export const AppsLibraryDesktop = ({ + availableQapps, + setMode, + myName, + hasPublishApp, + isShow, + categories = { categories }, +}) => { + const [searchValue, setSearchValue] = useState(""); + const virtuosoRef = useRef(); + + const officialApps = useMemo(() => { + return availableQapps.filter( + (app) => + app.service === "APP" && + officialAppList.includes(app?.name?.toLowerCase()) + ); + }, [availableQapps]); + + const [debouncedValue, setDebouncedValue] = useState(""); // Debounced value + + // 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 + + // Example: Perform search or other actions based on debouncedValue + + const searchedList = useMemo(() => { + if (!debouncedValue) return []; + return availableQapps.filter((app) => + app.name.toLowerCase().includes(debouncedValue.toLowerCase()) + ); + }, [debouncedValue]); + + const rowRenderer = (index) => { + let app = searchedList[index]; + return ( + + ); + }; + + return ( + + + + + + + + + + setSearchValue(e.target.value)} + sx={{ ml: 1, flex: 1 }} + placeholder="Search for apps" + inputProps={{ + "aria-label": "Search for apps", + fontSize: "16px", + fontWeight: 400, + }} + /> + + + {searchValue && ( + { + setSearchValue(""); + }} + > + + + )} + + + + + + + + + + { + executeEvent("navigateBack", {}); + }}> + + Return to Apps Dashboard + + + {searchedList?.length > 0 ? ( + + + + + + ) : ( + <> + + Official Apps + + + + {officialApps?.map((qapp) => { + return ( + { + // executeEvent("addTab", { + // data: qapp + // }) + executeEvent("selectedAppInfo", { + data: qapp, + }); + }} + > + + + + center-icon + + + + {qapp?.metadata?.title || qapp?.name} + + + + ); + })} + + + + + + {hasPublishApp ? "Update Apps!" : "Create Apps!"} + + + + + + + + + + + { + setMode("publish"); + }} + > + + {hasPublishApp ? "Update" : "Publish"} + + + + + + + + Categories + + + + {categories?.map((category) => { + return ( + { + executeEvent("selectedCategory", { + data: category, + }); + }} + > + + {category?.name} + + + ); + })} + + + + + )} + + + + ); +}; diff --git a/src/components/Apps/AppsNavBar.tsx b/src/components/Apps/AppsNavBar.tsx new file mode 100644 index 0000000..e387a08 --- /dev/null +++ b/src/components/Apps/AppsNavBar.tsx @@ -0,0 +1,347 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + AppsNavBarLeft, + AppsNavBarParent, + AppsNavBarRight, +} from "./Apps-styles"; +import NavBack from "../../assets/svgs/NavBack.svg"; +import NavAdd from "../../assets/svgs/NavAdd.svg"; +import NavMoreMenu from "../../assets/svgs/NavMoreMenu.svg"; +import { + ButtonBase, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Tab, + Tabs, +} from "@mui/material"; +import { + executeEvent, + subscribeToEvent, + unsubscribeFromEvent, +} from "../../utils/events"; +import TabComponent from "./TabComponent"; +import PushPinIcon from "@mui/icons-material/PushPin"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { useRecoilState, useSetRecoilState } from "recoil"; +import { + navigationControllerAtom, + settingsLocalLastUpdatedAtom, + sortablePinnedAppsAtom, +} from "../../atoms/global"; + +export function saveToLocalStorage(key, subKey, newValue) { + try { + // Fetch existing data + const existingData = localStorage.getItem(key); + let combinedData = {}; + + if (existingData) { + // Parse the existing data + const parsedData = JSON.parse(existingData); + // Merge with the new data under the subKey + combinedData = { + ...parsedData, + 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 = { + timestamp: Date.now(), // Set the initial root timestamp + [subKey]: newValue, + }; + } + + // Save combined data back to localStorage + const serializedValue = JSON.stringify(combinedData); + localStorage.setItem(key, serializedValue); + } catch (error) { + console.error("Error saving to localStorage:", error); + } +} + +export const AppsNavBar = () => { + const [tabs, setTabs] = useState([]); + const [selectedTab, setSelectedTab] = useState(null); + const [isNewTabWindow, setIsNewTabWindow] = useState(false); + const tabsRef = useRef(null); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const [sortablePinnedApps, setSortablePinnedApps] = useRecoilState( + sortablePinnedAppsAtom + ); + const [navigationController, setNavigationController] = useRecoilState(navigationControllerAtom) + + const isDisableBackButton = useMemo(()=> { + if(selectedTab && navigationController[selectedTab?.tabId]?.hasBack) return false + if(selectedTab && !navigationController[selectedTab?.tabId]?.hasBack) return true + return false + }, [navigationController, selectedTab]) + + const setSettingsLocalLastUpdated = useSetRecoilState( + settingsLocalLastUpdatedAtom + ); + + const handleClick = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + useEffect(() => { + // Scroll to the last tab whenever the tabs array changes (e.g., when a new tab is added) + if (tabsRef.current) { + const tabElements = tabsRef.current.querySelectorAll(".MuiTab-root"); + if (tabElements.length > 0) { + const lastTab = tabElements[tabElements.length - 1]; + lastTab.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "end", + }); + } + } + }, [tabs.length]); // Dependency on the number of tabs + + const setTabsToNav = (e) => { + const { tabs, selectedTab, isNewTabWindow } = e.detail?.data; + + setTabs([...tabs]); + setSelectedTab(!selectedTab ? null : { ...selectedTab }); + setIsNewTabWindow(isNewTabWindow); + }; + + useEffect(() => { + subscribeToEvent("setTabsToNav", setTabsToNav); + + return () => { + unsubscribeFromEvent("setTabsToNav", setTabsToNav); + }; + }, []); + + const isSelectedAppPinned = !!sortablePinnedApps?.find( + (item) => + item?.name === selectedTab?.name && item?.service === selectedTab?.service + ); + return ( + + + { + executeEvent("navigateBack", selectedTab?.tabId); + }} + disabled={isDisableBackButton} + sx={{ + opacity: !isDisableBackButton ? 1 : 0.1, + cursor: !isDisableBackButton ? 'pointer': 'default' + }} + > + + + + {tabs?.map((tab) => ( + + } // Pass custom component + sx={{ + "&.Mui-selected": { + color: "white", + }, + padding: "0px", + margin: "0px", + minWidth: "0px", + width: "50px", + }} + /> + ))} + + + {selectedTab && ( + + { + setSelectedTab(null); + executeEvent("newTabWindow", {}); + }} + > + + + { + if (!selectedTab) return; + handleClick(e); + }} + > + + + + )} + + + { + if (!selectedTab) return; + + setSortablePinnedApps((prev) => { + let updatedApps; + + if (isSelectedAppPinned) { + // Remove the selected app if it is pinned + updatedApps = prev.filter( + (item) => + !( + item?.name === selectedTab?.name && + item?.service === selectedTab?.service + ) + ); + } else { + // Add the selected app if it is not pinned + updatedApps = [ + ...prev, + { + name: selectedTab?.name, + service: selectedTab?.service, + }, + ]; + } + + saveToLocalStorage( + "ext_saved_settings", + "sortablePinnedApps", + updatedApps + ); + return updatedApps; + }); + setSettingsLocalLastUpdated(Date.now()); + + handleClose(); + }} + > + + + + + + { + executeEvent("refreshApp", { + tabId: selectedTab?.tabId, + }); + handleClose(); + }} + > + + + + + + + + ); +}; diff --git a/src/components/Apps/AppsNavBarDesktop.tsx b/src/components/Apps/AppsNavBarDesktop.tsx new file mode 100644 index 0000000..253cb98 --- /dev/null +++ b/src/components/Apps/AppsNavBarDesktop.tsx @@ -0,0 +1,372 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + AppsNavBarLeft, + AppsNavBarParent, + AppsNavBarRight, +} from "./Apps-styles"; +import NavBack from "../../assets/svgs/NavBack.svg"; +import NavAdd from "../../assets/svgs/NavAdd.svg"; +import NavMoreMenu from "../../assets/svgs/NavMoreMenu.svg"; +import { + ButtonBase, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Tab, + Tabs, +} from "@mui/material"; +import { + executeEvent, + subscribeToEvent, + unsubscribeFromEvent, +} from "../../utils/events"; +import TabComponent from "./TabComponent"; +import PushPinIcon from "@mui/icons-material/PushPin"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { useRecoilState, useSetRecoilState } from "recoil"; +import { + navigationControllerAtom, + settingsLocalLastUpdatedAtom, + sortablePinnedAppsAtom, +} from "../../atoms/global"; + +export function saveToLocalStorage(key, subKey, newValue) { + try { + // Fetch existing data + const existingData = localStorage.getItem(key); + let combinedData = {}; + + if (existingData) { + // Parse the existing data + const parsedData = JSON.parse(existingData); + // Merge with the new data under the subKey + combinedData = { + ...parsedData, + 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 = { + timestamp: Date.now(), // Set the initial root timestamp + [subKey]: newValue, + }; + } + + // Save combined data back to localStorage + const serializedValue = JSON.stringify(combinedData); + localStorage.setItem(key, serializedValue); + } catch (error) { + console.error("Error saving to localStorage:", error); + } +} + +export const AppsNavBarDesktop = () => { + const [tabs, setTabs] = useState([]); + const [selectedTab, setSelectedTab] = useState(null); + const [navigationController, setNavigationController] = useRecoilState(navigationControllerAtom) + + const [isNewTabWindow, setIsNewTabWindow] = useState(false); + const tabsRef = useRef(null); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const [sortablePinnedApps, setSortablePinnedApps] = useRecoilState( + sortablePinnedAppsAtom + ); + + + const setSettingsLocalLastUpdated = useSetRecoilState( + settingsLocalLastUpdatedAtom + ); + + const handleClick = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + useEffect(() => { + // Scroll to the last tab whenever the tabs array changes (e.g., when a new tab is added) + if (tabsRef.current) { + const tabElements = tabsRef.current.querySelectorAll(".MuiTab-root"); + if (tabElements.length > 0) { + const lastTab = tabElements[tabElements.length - 1]; + lastTab.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "end", + }); + } + } + }, [tabs.length]); // Dependency on the number of tabs + + + + const isDisableBackButton = useMemo(()=> { + if(selectedTab && navigationController[selectedTab?.tabId]?.hasBack) return false + if(selectedTab && !navigationController[selectedTab?.tabId]?.hasBack) return true + return false + }, [navigationController, selectedTab]) + + + + + const setTabsToNav = (e) => { + const { tabs, selectedTab, isNewTabWindow } = e.detail?.data; + + setTabs([...tabs]); + setSelectedTab(!selectedTab ? null : { ...selectedTab }); + setIsNewTabWindow(isNewTabWindow); + }; + + useEffect(() => { + subscribeToEvent("setTabsToNav", setTabsToNav); + + return () => { + unsubscribeFromEvent("setTabsToNav", setTabsToNav); + }; + }, []); + + + + const isSelectedAppPinned = !!sortablePinnedApps?.find( + (item) => + item?.name === selectedTab?.name && item?.service === selectedTab?.service + ); + return ( + + + { + executeEvent("navigateBack", selectedTab?.tabId); + }} + disabled={isDisableBackButton} + sx={{ + opacity: !isDisableBackButton ? 1 : 0.1, + cursor: !isDisableBackButton ? 'pointer': 'default' + }} + > + + + + {tabs?.map((tab) => ( + + } // Pass custom component + sx={{ + "&.Mui-selected": { + color: "white", + }, + padding: "0px", + margin: "0px", + minWidth: "0px", + width: "50px", + }} + /> + ))} + + + {selectedTab && ( + + { + setSelectedTab(null); + executeEvent("newTabWindow", {}); + }} + > + + + { + if (!selectedTab) return; + handleClick(e); + }} + > + + + + )} + + + { + if (!selectedTab) return; + + setSortablePinnedApps((prev) => { + let updatedApps; + + if (isSelectedAppPinned) { + // Remove the selected app if it is pinned + updatedApps = prev.filter( + (item) => + !( + item?.name === selectedTab?.name && + item?.service === selectedTab?.service + ) + ); + } else { + // Add the selected app if it is not pinned + updatedApps = [ + ...prev, + { + name: selectedTab?.name, + service: selectedTab?.service, + }, + ]; + } + + saveToLocalStorage( + "ext_saved_settings", + "sortablePinnedApps", + updatedApps + ); + return updatedApps; + }); + setSettingsLocalLastUpdated(Date.now()); + + handleClose(); + }} + > + + + + + + { + executeEvent("refreshApp", { + tabId: selectedTab?.tabId, + }); + handleClose(); + }} + > + + + + + + + + ); +}; diff --git a/src/components/Apps/SortablePinnedApps.tsx b/src/components/Apps/SortablePinnedApps.tsx new file mode 100644 index 0000000..1a44a2f --- /dev/null +++ b/src/components/Apps/SortablePinnedApps.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { DndContext, closestCenter } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable } from '@dnd-kit/sortable'; +import { KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { CSS } from '@dnd-kit/utilities'; +import { Avatar, ButtonBase } from '@mui/material'; +import { AppCircle, AppCircleContainer, AppCircleLabel } from './Apps-styles'; +import { getBaseApiReact } from '../../App'; +import { executeEvent } from '../../utils/events'; +import { settingsLocalLastUpdatedAtom, sortablePinnedAppsAtom } from '../../atoms/global'; +import { useRecoilState, useSetRecoilState } from 'recoil'; +import { saveToLocalStorage } from './AppsNavBar'; +import { ContextMenuPinnedApps } from '../ContextMenuPinnedApps'; + +const SortableItem = ({ id, name, app, isDesktop }) => { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + padding: '10px', + border: '1px solid #ccc', + marginBottom: '5px', + borderRadius: '4px', + backgroundColor: '#f9f9f9', + cursor: 'grab', + color: 'black' + }; + + return ( + + { + executeEvent("addTab", { + data: app + }) + }} + > + + + + center-icon + + + + {app?.metadata?.title || app?.name} + + + + + ); +}; + +export const SortablePinnedApps = ({ isDesktop, myWebsite, myApp, availableQapps = [] }) => { + const [pinnedApps, setPinnedApps] = useRecoilState(sortablePinnedAppsAtom); + const setSettingsLocalLastUpdated = useSetRecoilState(settingsLocalLastUpdatedAtom); + + const transformPinnedApps = useMemo(() => { + + // Clone the existing pinned apps list + let pinned = [...pinnedApps]; + + // Function to add or update `isMine` property + const addOrUpdateIsMine = (pinnedList, appToCheck) => { + if (!appToCheck) return pinnedList; + + const existingIndex = pinnedList.findIndex( + (item) => item?.service === appToCheck?.service && item?.name === appToCheck?.name + ); + + if (existingIndex !== -1) { + // If the app is already in the list, update it with `isMine: true` + pinnedList[existingIndex] = { ...pinnedList[existingIndex], isMine: true }; + } else { + // If not in the list, add it with `isMine: true` at the beginning + pinnedList.unshift({ ...appToCheck, isMine: true }); + } + + return pinnedList; + }; + + // Update or add `myWebsite` and `myApp` while preserving their positions + pinned = addOrUpdateIsMine(pinned, myWebsite); + pinned = addOrUpdateIsMine(pinned, myApp); + + // Update pinned list based on availableQapps + pinned = pinned.map((pin) => { + const findIndex = availableQapps?.findIndex( + (item) => item?.service === pin?.service && item?.name === pin?.name + ); + if (findIndex !== -1) return { + ...availableQapps[findIndex], + ...pin + } + + return pin; + }); + + return pinned; + }, [myApp, myWebsite, pinnedApps, availableQapps]); + + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 10, // Set a distance to avoid triggering drag on small movements + }, + }), + useSensor(TouchSensor, { + activationConstraint: { + distance: 10, // Also apply to touch + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = (event) => { + const { active, over } = event; + + if (!over) return; // Make sure the drop target exists + + if (active.id !== over.id) { + const oldIndex = transformPinnedApps.findIndex((item) => `${item?.service}-${item?.name}` === active.id); + const newIndex = transformPinnedApps.findIndex((item) => `${item?.service}-${item?.name}` === over.id); + + const newOrder = arrayMove(transformPinnedApps, oldIndex, newIndex); + setPinnedApps(newOrder); + saveToLocalStorage('ext_saved_settings','sortablePinnedApps', newOrder) + setSettingsLocalLastUpdated(Date.now()) + } + }; + return ( + + `${app?.service}-${app?.name}`)}> + {transformPinnedApps.map((app) => ( + + ))} + + + ); +}; + diff --git a/src/components/Apps/TabComponent.tsx b/src/components/Apps/TabComponent.tsx new file mode 100644 index 0000000..aca6b55 --- /dev/null +++ b/src/components/Apps/TabComponent.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { TabParent } from './Apps-styles' +import NavCloseTab from "../../assets/svgs/NavCloseTab.svg"; +import { getBaseApiReact } from '../../App'; +import { Avatar, ButtonBase } from '@mui/material'; +import LogoSelected from "../../assets/svgs/LogoSelected.svg"; +import { executeEvent } from '../../utils/events'; + +const TabComponent = ({isSelected, app}) => { + return ( + { + if(isSelected){ + executeEvent('removeTab', { + data: app + }) + return + } + executeEvent('setSelectedTab', { + data: app + }) + }}> + + {isSelected && ( + + + + ) } + + center-icon + + + + ) +} + +export default TabComponent \ No newline at end of file diff --git a/src/components/Apps/useQortalMessageListener.tsx b/src/components/Apps/useQortalMessageListener.tsx new file mode 100644 index 0000000..11e1680 --- /dev/null +++ b/src/components/Apps/useQortalMessageListener.tsx @@ -0,0 +1,484 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import FileSaver from 'file-saver'; +import { executeEvent } from '../../utils/events'; +import { useSetRecoilState } from 'recoil'; +import { navigationControllerAtom } from '../../atoms/global'; +class Semaphore { + constructor(count) { + this.count = count + this.waiting = [] + } + acquire() { + return new Promise(resolve => { + if (this.count > 0) { + this.count-- + resolve() + } else { + this.waiting.push(resolve) + } + }) + } + release() { + if (this.waiting.length > 0) { + const resolve = this.waiting.shift() + resolve() + } else { + this.count++ + } + } +} +let semaphore = new Semaphore(1) +let reader = new FileReader() + +const fileToBase64 = (file) => new Promise(async (resolve, reject) => { + if (!reader) { + reader = new FileReader() + } + await semaphore.acquire() + reader.readAsDataURL(file) + reader.onload = () => { + const dataUrl = reader.result + if (typeof dataUrl === "string") { + const base64String = dataUrl.split(',')[1] + reader.onload = null + reader.onerror = null + resolve(base64String) + } else { + reader.onload = null + reader.onerror = null + reject(new Error('Invalid data URL')) + } + semaphore.release() + } + reader.onerror = (error) => { + reader.onload = null + reader.onerror = null + reject(error) + semaphore.release() + } +}) + +function openIndexedDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open("fileStorageDB", 1); + + request.onupgradeneeded = function (event) { + const db = event.target.result; + if (!db.objectStoreNames.contains("files")) { + db.createObjectStore("files", { keyPath: "id" }); + } + }; + + request.onsuccess = function (event) { + resolve(event.target.result); + }; + + request.onerror = function () { + reject("Error opening IndexedDB"); + }; + }); + } + +async function handleGetFileFromIndexedDB(fileId, sendResponse) { + try { + const db = await openIndexedDB(); + const transaction = db.transaction(["files"], "readonly"); + const objectStore = transaction.objectStore("files"); + + const getRequest = objectStore.get(fileId); + + getRequest.onsuccess = async function (event) { + if (getRequest.result) { + const file = getRequest.result.data; + + try { + const base64String = await fileToBase64(file); + + // Create a new transaction to delete the file + const deleteTransaction = db.transaction(["files"], "readwrite"); + const deleteObjectStore = deleteTransaction.objectStore("files"); + const deleteRequest = deleteObjectStore.delete(fileId); + + deleteRequest.onsuccess = function () { + try { + sendResponse({ result: base64String }); + + } catch (error) { + console.log('error', error) + } + }; + + deleteRequest.onerror = function () { + console.error(`Error deleting file with ID ${fileId} from IndexedDB`); + sendResponse({ result: null, error: "Failed to delete file from IndexedDB" }); + }; + } catch (error) { + console.error("Error converting file to Base64:", error); + sendResponse({ result: null, error: "Failed to convert file to Base64" }); + } + } else { + console.error(`File with ID ${fileId} not found in IndexedDB`); + sendResponse({ result: null, error: "File not found in IndexedDB" }); + } + }; + + getRequest.onerror = function () { + console.error(`Error retrieving file with ID ${fileId} from IndexedDB`); + sendResponse({ result: null, error: "Error retrieving file from IndexedDB" }); + }; + } catch (error) { + console.error("Error opening IndexedDB:", error); + sendResponse({ result: null, error: "Error opening IndexedDB" }); + } + } + +const UIQortalRequests = [ + 'GET_USER_ACCOUNT', 'DECRYPT_DATA', 'SEND_COIN', 'GET_LIST_ITEMS', + 'ADD_LIST_ITEMS', 'DELETE_LIST_ITEM', 'VOTE_ON_POLL', 'CREATE_POLL', + 'SEND_CHAT_MESSAGE', 'JOIN_GROUP', 'DEPLOY_AT', 'GET_USER_WALLET', + 'GET_WALLET_BALANCE', 'GET_USER_WALLET_INFO', 'GET_CROSSCHAIN_SERVER_INFO', + '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' +]; + + + + + + async function retrieveFileFromIndexedDB(fileId) { + const db = await openIndexedDB(); + const transaction = db.transaction(["files"], "readwrite"); + const objectStore = transaction.objectStore("files"); + + return new Promise((resolve, reject) => { + const getRequest = objectStore.get(fileId); + + getRequest.onsuccess = function (event) { + if (getRequest.result) { + // File found, resolve it and delete from IndexedDB + const file = getRequest.result.data; + objectStore.delete(fileId); + resolve(file); + } else { + reject("File not found in IndexedDB"); + } + }; + + getRequest.onerror = function () { + reject("Error retrieving file from IndexedDB"); + }; + }); + } + + async function deleteQortalFilesFromIndexedDB() { + try { + const db = await openIndexedDB(); + const transaction = db.transaction(["files"], "readwrite"); + const objectStore = transaction.objectStore("files"); + + // Create a request to get all keys + const getAllKeysRequest = objectStore.getAllKeys(); + + getAllKeysRequest.onsuccess = function (event) { + const keys = event.target.result; + + // Iterate through keys to find and delete those containing '_qortalfile' + for (let key of keys) { + if (key.includes("_qortalfile")) { + const deleteRequest = objectStore.delete(key); + + deleteRequest.onsuccess = function () { + console.log(`File with key '${key}' has been deleted from IndexedDB`); + }; + + deleteRequest.onerror = function () { + console.error(`Failed to delete file with key '${key}' from IndexedDB`); + }; + } + } + }; + + getAllKeysRequest.onerror = function () { + console.error("Failed to retrieve keys from IndexedDB"); + }; + + transaction.oncomplete = function () { + console.log("Transaction complete for deleting files from IndexedDB"); + }; + + transaction.onerror = function () { + console.error("Error occurred during transaction for deleting files"); + }; + } catch (error) { + console.error("Error opening IndexedDB:", error); + } + } + + const showSaveFilePicker = async (data) => { + let blob + let fileName + try { + const {filename, mimeType, fileHandleOptions, fileId} = data + blob = await retrieveFileFromIndexedDB(fileId) + fileName = filename + + const fileHandle = await window.showSaveFilePicker({ + suggestedName: filename, + types: [ + { + description: mimeType, + ...fileHandleOptions + } + ] + }) + const writeFile = async (fileHandle, contents) => { + const writable = await fileHandle.createWritable() + await writable.write(contents) + await writable.close() + } + writeFile(fileHandle, blob).then(() => console.log("FILE SAVED")) + } catch (error) { + FileSaver.saveAs(blob, fileName) + } + } + + async function storeFilesInIndexedDB(obj) { + // First delete any existing files in IndexedDB with '_qortalfile' in their ID + await deleteQortalFilesFromIndexedDB(); + + // Open the IndexedDB + const db = await openIndexedDB(); + const transaction = db.transaction(["files"], "readwrite"); + const objectStore = transaction.objectStore("files"); + + // Handle the obj.file if it exists and is a File instance + if (obj.file) { + const fileId = "objFile_qortalfile"; + + // Store the file in IndexedDB + const fileData = { + id: fileId, + data: obj.file, + }; + objectStore.put(fileData); + + // Replace the file object with the file ID in the original object + obj.fileId = fileId; + delete obj.file; + } + if (obj.blob) { + const fileId = "objFile_qortalfile"; + + // Store the file in IndexedDB + const fileData = { + id: fileId, + data: obj.blob, + }; + objectStore.put(fileData); + + // Replace the file object with the file ID in the original object + let blobObj = { + type: obj.blob?.type + } + obj.fileId = fileId; + delete obj.blob; + obj.blob = blobObj + } + + // Iterate through resources to find files and save them to IndexedDB + for (let resource of (obj?.resources || [])) { + if (resource.file) { + const fileId = resource.identifier + "_qortalfile"; + + // Store the file in IndexedDB + const fileData = { + id: fileId, + data: resource.file, + }; + objectStore.put(fileData); + + // Replace the file object with the file ID in the original object + resource.fileId = fileId; + delete resource.file; + } + } + + // Set transaction completion handlers + transaction.oncomplete = function () { + console.log("Files saved successfully to IndexedDB"); + }; + + transaction.onerror = function () { + console.error("Error saving files to IndexedDB"); + }; + + return obj; // Updated object with references to stored files + } + +export const useQortalMessageListener = (frameWindow, iframeRef, tabId) => { + const [path, setPath] = useState('') + const [history, setHistory] = useState({ + customQDNHistoryPaths: [], +currentIndex: -1, +isDOMContentLoaded: false + }) + const setHasSettingsChangedAtom = useSetRecoilState(navigationControllerAtom); + + + useEffect(()=> { + if(tabId && !isNaN(history?.currentIndex)){ + setHasSettingsChangedAtom((prev)=> { + return { + ...prev, + [tabId]: { + hasBack: history?.currentIndex > 0, + } + } + }) + } + }, [history?.currentIndex, tabId]) + + + const changeCurrentIndex = useCallback((value)=> { + setHistory((prev)=> { + return { + ...prev, + currentIndex: value + } + }) + }, []) + + const resetHistory = useCallback(()=> { + setHistory({ + customQDNHistoryPaths: [], + currentIndex: -1, + isManualNavigation: true, + isDOMContentLoaded: false + }) + }, []) + + useEffect(() => { + + const listener = async (event) => { + console.log('eventreactt', event) + // event.preventDefault(); // Prevent default behavior + // event.stopImmediatePropagation(); // Stop other listeners from firing + + if (event?.data?.requestedHandler !== 'UI') return; + + const sendMessageToRuntime = (message, eventPort) => { + chrome?.runtime?.sendMessage(message, (response) => { + if (response.error) { + eventPort.postMessage({ + result: null, + error: response, + }); + } else { + eventPort.postMessage({ + result: response, + error: null, + }); + } + }); + }; + + // Check if action is included in the predefined list of UI requests + if (UIQortalRequests.includes(event.data.action)) { + sendMessageToRuntime( + { action: event.data.action, type: 'qortalRequest', payload: event.data, isExtension: true }, + event.ports[0] + ); + } 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' + + ) { + let data; + try { + data = await storeFilesInIndexedDB(event.data); + } catch (error) { + console.error('Error storing files in IndexedDB:', error); + event.ports[0].postMessage({ + result: null, + error: 'Failed to store files in IndexedDB', + }); + return; + } + if (data) { + sendMessageToRuntime( + { action: event.data.action, type: 'qortalRequest', payload: data, isExtension: true }, + event.ports[0] + ); + } else { + event.ports[0].postMessage({ + result: null, + error: 'Failed to prepare data for publishing', + }); + } + } else if(event?.data?.action === 'LINK_TO_QDN_RESOURCE' || + event?.data?.action === 'QDN_RESOURCE_DISPLAYED'){ + const pathUrl = event?.data?.path != null ? (event?.data?.path.startsWith('/') ? '' : '/') + event?.data?.path : null + setPath(pathUrl) + } else if(event?.data?.action === 'NAVIGATION_HISTORY'){ + if(event?.data?.payload?.isDOMContentLoaded){ + setHistory((prev)=> { + const copyPrev = {...prev} + if((copyPrev?.customQDNHistoryPaths || []).at(-1) === (event?.data?.payload?.customQDNHistoryPaths || []).at(-1)) { + console.log('customQDNHistoryPaths.length', prev?.customQDNHistoryPaths.length) + return { + ...prev, + currentIndex: prev.customQDNHistoryPaths.length - 1 === -1 ? 0 : prev.customQDNHistoryPaths.length - 1 + } + } + const copyHistory = {...prev} + const paths = [...(copyHistory?.customQDNHistoryPaths || []), ...(event?.data?.payload?.customQDNHistoryPaths || [])] + console.log('paths', paths) + return { + ...prev, + customQDNHistoryPaths: paths, + currentIndex: paths.length - 1 + } + }) + } else { + setHistory(event?.data?.payload) + + } + } else if(event?.data?.action === 'SET_TAB'){ + executeEvent("addTab", { + data: event?.data?.payload + }) + iframeRef.current.contentWindow.postMessage( + { action: 'SET_TAB_SUCCESS', requestedHandler: 'UI',payload: { + name: event?.data?.payload?.name + } }, '*' + ); + } + }; + + // Add the listener for messages coming from the frameWindow + frameWindow.addEventListener('message', listener); + + // Cleanup function to remove the event listener when the component is unmounted + return () => { + frameWindow.removeEventListener('message', listener); + }; + + + }, []); // Empty dependency array to run once when the component mounts + + chrome.runtime?.onMessage.addListener( function (message, sender, sendResponse) { + if(message.action === "SHOW_SAVE_FILE_PICKER"){ + showSaveFilePicker(message?.data) + } + + else if (message.action === "getFileFromIndexedDB") { + handleGetFileFromIndexedDB(message.fileId, sendResponse); + return true; // Keep the message channel open for async response + } + }); + + return {path, history, resetHistory, changeCurrentIndex} +}; + diff --git a/src/components/Chat/AnnouncementDiscussion.tsx b/src/components/Chat/AnnouncementDiscussion.tsx index a162c8b..286ce95 100644 --- a/src/components/Chat/AnnouncementDiscussion.tsx +++ b/src/components/Chat/AnnouncementDiscussion.tsx @@ -255,7 +255,7 @@ export const AnnouncementDiscussion = ({ return (
{ + try { + if (hasInitialized.current) { + decryptMessages(data, true); + return; + } + hasInitialized.current = true; + const url = `${getBaseApiReact()}/chat/messages?involving=${selectedDirectAddress}&involving=${myAddress}&encoding=BASE64&limit=0&reverse=false`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const responseData = await response.json(); + decryptMessages(responseData, false); + } catch (error) { + console.error(error); + } + } - const decryptMessages = (encryptedMessages: any[])=> { + const decryptMessages = (encryptedMessages: any[], isInitiated: boolean)=> { try { return new Promise((res, rej)=> { chrome?.runtime?.sendMessage({ action: "decryptDirect", payload: { @@ -92,7 +111,7 @@ export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDi processWithNewMessages(response, selectedDirect?.address) res(response) - if(hasInitialized.current){ + if(isInitiated){ const formatted = response.map((item: any)=> { return { @@ -127,7 +146,6 @@ export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDi const forceCloseWebSocket = () => { if (socketRef.current) { - console.log('Force closing the WebSocket'); clearTimeout(timeoutIdRef.current); clearTimeout(groupSocketTimeoutRef.current); socketRef.current.close(1000, 'forced'); @@ -161,7 +179,6 @@ export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDi socketRef.current = new WebSocket(socketLink); socketRef.current.onopen = () => { - console.log('WebSocket connection opened'); setTimeout(pingWebSocket, 50); // Initial ping }; @@ -171,7 +188,8 @@ export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDi clearTimeout(timeoutIdRef.current); groupSocketTimeoutRef.current = setTimeout(pingWebSocket, 45000); // Ping every 45 seconds } else { - decryptMessages(JSON.parse(e.data)); + middletierFunc(JSON.parse(e.data), selectedDirect?.address, myAddress) + setIsLoading(false); } } catch (error) { diff --git a/src/components/Chat/ChatGroup.tsx b/src/components/Chat/ChatGroup.tsx index 9c9d736..0cf2cc6 100644 --- a/src/components/Chat/ChatGroup.tsx +++ b/src/components/Chat/ChatGroup.tsx @@ -97,27 +97,28 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, } const middletierFunc = async (data: any, groupId: string) => { - try { - if (hasInitialized.current) { - decryptMessages(data, true); - return; - } - hasInitialized.current = true; - const url = `${getBaseApiReact()}/chat/messages?txGroupId=${groupId}&encoding=BASE64&limit=0&reverse=false`; - const response = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - const responseData = await response.json(); - decryptMessages(responseData, false); - } catch (error) { - console.error(error); + try { + if (hasInitialized.current) { + decryptMessages(data, true); + return; } - } + hasInitialized.current = true; + const url = `${getBaseApiReact()}/chat/messages?txGroupId=${groupId}&encoding=BASE64&limit=0&reverse=false`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const responseData = await response.json(); + decryptMessages(responseData, false); + } catch (error) { + console.error(error); + } + } - const decryptMessages = ( encryptedMessages: any[], isInitiated: boolean )=> { + + const decryptMessages = (encryptedMessages: any[], isInitiated: boolean )=> { try { if(!secretKeyRef.current){ checkForFirstSecretKeyNotification(encryptedMessages) @@ -231,6 +232,7 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, } } ) setMessages(formatted) + setChatReferences((prev) => { let organizedChatReferences = { ...prev }; diff --git a/src/components/Chat/ChatList.tsx b/src/components/Chat/ChatList.tsx index 2b5edce..284fc6f 100644 --- a/src/components/Chat/ChatList.tsx +++ b/src/components/Chat/ChatList.tsx @@ -1,21 +1,33 @@ -import React, { useCallback, useState, useEffect, useRef } from 'react'; -import { Virtuoso } from 'react-virtuoso'; +import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { MessageItem } from './MessageItem'; import { subscribeToEvent, unsubscribeFromEvent } from '../../utils/events'; +import { useInView } from 'react-intersection-observer' export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onReply, handleReaction, chatReferences, tempChatReferences }) => { - - const virtuosoRef = useRef(); + const parentRef = useRef(); const [messages, setMessages] = useState(initialMessages); const [showScrollButton, setShowScrollButton] = useState(false); const hasLoadedInitialRef = useRef(false); - const isAtBottomRef = useRef(true); // + const isAtBottomRef = useRef(true); + // const [ref, inView] = useInView({ + // threshold: 0.7 + // }) + + // useEffect(() => { + // if (inView) { + + // } + // }, [inView]) // Update message list with unique signatures and tempMessages useEffect(() => { let uniqueInitialMessagesMap = new Map(); + // Only add a message if it doesn't already exist in the Map initialMessages.forEach((message) => { - uniqueInitialMessagesMap.set(message.signature, message); + if (!uniqueInitialMessagesMap.has(message.signature)) { + uniqueInitialMessagesMap.set(message.signature, message); + } }); const uniqueInitialMessages = Array.from(uniqueInitialMessagesMap.values()).sort( @@ -29,22 +41,14 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR setTimeout(() => { const hasUnreadMessages = totalMessages.some((msg) => msg.unread && !msg?.chatReference); - - if (virtuosoRef.current) { - - - if (virtuosoRef.current && !isAtBottomRef.current && hasUnreadMessages) { - - - - - setShowScrollButton(hasUnreadMessages); + if (parentRef.current) { + const { scrollTop, scrollHeight, clientHeight } = parentRef.current; + const atBottom = scrollTop + clientHeight >= scrollHeight - 10; // Adjust threshold as needed + if (!atBottom && hasUnreadMessages) { + setShowScrollButton(hasUnreadMessages); } else { handleMessageSeen(); - } - - } if (!hasLoadedInitialRef.current) { scrollToBottom(totalMessages); @@ -53,7 +57,14 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR }, 500); }, [initialMessages, tempMessages]); - + const scrollToBottom = (initialMsgs) => { + const index = initialMsgs ? initialMsgs.length - 1 : messages.length - 1; + if (rowVirtualizer) { + rowVirtualizer.scrollToIndex(index, { align: 'end' }); + } + handleMessageSeen() + }; + const handleMessageSeen = useCallback(() => { setMessages((prevMessages) => @@ -62,34 +73,16 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR unread: false, })) ); + setShowScrollButton(false) }, []); - const scrollToItem = useCallback((index) => { - if (virtuosoRef.current) { - virtuosoRef.current.scrollToIndex({ index, behavior: 'smooth' }); - } - }, []); + // const scrollToBottom = (initialMsgs) => { + // const index = initialMsgs ? initialMsgs.length - 1 : messages.length - 1; + // if (parentRef.current) { + // parentRef.current.scrollToIndex(index); + // } + // }; - const scrollToBottom = (initialMsgs) => { - - const index = initialMsgs ? initialMsgs.length - 1 : messages.length - 1 - if (virtuosoRef.current) { - virtuosoRef.current.scrollToIndex({ index}); - } - }; - - - const handleScroll = (scrollState) => { - const { scrollTop, scrollHeight, clientHeight } = scrollState; - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; - const hasUnreadMessages = messages.some((msg) => msg.unread); - - if (isAtBottom) { - handleMessageSeen(); - } - - setShowScrollButton(!isAtBottom && hasUnreadMessages); - }; const sentNewMessageGroupFunc = useCallback(() => { scrollToBottom(); @@ -102,96 +95,134 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR }; }, [sentNewMessageGroupFunc]); - const rowRenderer = (index) => { - let message = messages[index]; - let replyIndex = messages.findIndex((msg)=> msg?.signature === message?.repliedTo) - let reply - let reactions = null - if(message?.repliedTo && replyIndex !== -1){ - reply = messages[replyIndex] - } - if(message?.message && message?.groupDirectId){ - replyIndex = messages.findIndex((msg)=> msg?.signature === message?.message?.repliedTo) - reply - if(message?.message?.repliedTo && replyIndex !== -1){ - reply = messages[replyIndex] - } - message = { - ...(message?.message || {}), - isTemp: true, - unread: false + const lastSignature = useMemo(()=> { + if(!messages || messages?.length === 0) return null + const lastIndex = messages.length - 1 + 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 + measureElement: + typeof window !== 'undefined' && + navigator.userAgent.indexOf('Firefox') === -1 + ? element => { + return element?.getBoundingClientRect().height } - } - - if(chatReferences && chatReferences[message?.signature]){ - if(chatReferences[message.signature]?.reactions){ - reactions = chatReferences[message.signature]?.reactions - } - } - let isUpdating = false - if(tempChatReferences && tempChatReferences?.find((item)=> item?.chatReference === message?.signature)){ - isUpdating = true - } - - return ( -
- -
- ); - }; - - const handleAtBottomStateChange = (atBottom) => { - isAtBottomRef.current = atBottom; - if(atBottom){ - handleMessageSeen(); - setShowScrollButton(false) - } - }; + : undefined, + }); return ( -
- +<> +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const index = virtualRow.index; + let message = messages[index]; + let replyIndex = messages.findIndex((msg) => msg?.signature === message?.repliedTo); + let reply; + let reactions = null; - {showScrollButton && ( - - )} + if (message?.repliedTo && replyIndex !== -1) { + reply = messages[replyIndex]; + } + + if (message?.message && message?.groupDirectId) { + replyIndex = messages.findIndex((msg) => msg?.signature === message?.message?.repliedTo); + if (message?.message?.repliedTo && replyIndex !== -1) { + reply = messages[replyIndex]; + } + message = { + ...(message?.message || {}), + isTemp: true, + unread: false, + }; + } + + if (chatReferences && chatReferences[message?.signature]) { + if (chatReferences[message.signature]?.reactions) { + reactions = chatReferences[message.signature]?.reactions; + } + } + + let isUpdating = false; + if (tempChatReferences && tempChatReferences?.find((item) => item?.chatReference === message?.signature)) { + isUpdating = true; + } + + return ( +
rowVirtualizer.measureElement(node)} //measure dynamic row height + key={message.signature} + style={{ + position: 'absolute', + top: 0, + left: '50%', // Move to the center horizontally + transform: `translateY(${virtualRow.start}px) translateX(-50%)`, // Adjust for centering + width: '100%', // Control width (90% of the parent) + padding: '10px 0', + display: 'flex', + justifyContent: 'center', + overscrollBehavior: 'none', + }} + > + rowVirtualizer.scrollToIndex(idx)} + handleReaction={handleReaction} + reactions={reactions} + isUpdating={isUpdating} + /> +
+ ); + })} +
+ +
+ {showScrollButton && ( + + )} + + ); }; diff --git a/src/components/Chat/MessageItem.tsx b/src/components/Chat/MessageItem.tsx index a43170d..681130b 100644 --- a/src/components/Chat/MessageItem.tsx +++ b/src/components/Chat/MessageItem.tsx @@ -29,7 +29,8 @@ export const MessageItem = ({ scrollToItem, handleReaction, reactions, - isUpdating + isUpdating, + lastSignature }) => { const { ref, inView } = useInView({ threshold: 0.7, // Fully visible @@ -42,9 +43,10 @@ export const MessageItem = ({ } }, [inView, message.id, message.unread, onSeen]); + return (
({ + '& .MuiPaper-root': { + backgroundColor: '#f9f9f9', + borderRadius: '12px', + padding: theme.spacing(1), + boxShadow: '0 5px 15px rgba(0, 0, 0, 0.2)', + }, + '& .MuiMenuItem-root': { + fontSize: '14px', + color: '#444', + transition: '0.3s background-color', + '&:hover': { + backgroundColor: '#f0f0f0', + }, + }, +})); + +export const ContextMenuPinnedApps = ({ children, app, isMine }) => { + const [menuPosition, setMenuPosition] = useState(null); + const longPressTimeout = useRef(null); + const maxHoldTimeout = useRef(null); + const preventClick = useRef(false); + const startTouchPosition = useRef({ x: 0, y: 0 }); // Track initial touch position + const [sortablePinnedApps, setSortablePinnedApps] = useRecoilState(sortablePinnedAppsAtom); + + const handleContextMenu = (event) => { + if(isMine) return + event.preventDefault(); + event.stopPropagation(); + preventClick.current = true; + setMenuPosition({ + mouseX: event.clientX, + mouseY: event.clientY, + }); + }; + + const handleTouchStart = (event) => { + if(isMine) return + + const { clientX, clientY } = event.touches[0]; + startTouchPosition.current = { x: clientX, y: clientY }; + + longPressTimeout.current = setTimeout(() => { + preventClick.current = true; + + event.stopPropagation(); + setMenuPosition({ + mouseX: clientX, + mouseY: clientY, + }); + }, 500); + + // Set a maximum hold duration (e.g., 1.5 seconds) + maxHoldTimeout.current = setTimeout(() => { + clearTimeout(longPressTimeout.current); + }, 1500); + }; + + const handleTouchMove = (event) => { + if(isMine) return + + const { clientX, clientY } = event.touches[0]; + const { x, y } = startTouchPosition.current; + + // Determine if the touch has moved beyond a small threshold (e.g., 10px) + const movedEnough = Math.abs(clientX - x) > 10 || Math.abs(clientY - y) > 10; + + if (movedEnough) { + clearTimeout(longPressTimeout.current); + clearTimeout(maxHoldTimeout.current); + } + }; + + const handleTouchEnd = (event) => { + if(isMine) return + + clearTimeout(longPressTimeout.current); + clearTimeout(maxHoldTimeout.current); + if (preventClick.current) { + event.preventDefault(); + event.stopPropagation(); + preventClick.current = false; + } + }; + + const handleClose = (e) => { + if(isMine) return + + e.preventDefault(); + e.stopPropagation(); + setMenuPosition(null); + }; + + return ( +
+ {children} + { + e.stopPropagation(); + }} + > + { + handleClose(e); + setSortablePinnedApps((prev) => { + const updatedApps = prev.filter( + (item) => !(item?.name === app?.name && item?.service === app?.service) + ); + saveToLocalStorage('ext_saved_settings', 'sortablePinnedApps', updatedApps); + return updatedApps; + }); + }}> + + + + + Unpin app + + + +
+ ); +}; diff --git a/src/components/Desktop/DesktopFooter.tsx b/src/components/Desktop/DesktopFooter.tsx index 4eafe8b..f1cf754 100644 --- a/src/components/Desktop/DesktopFooter.tsx +++ b/src/components/Desktop/DesktopFooter.tsx @@ -13,21 +13,24 @@ import { WalletIcon } from "../../assets/Icons/WalletIcon"; import { HubsIcon } from "../../assets/Icons/HubsIcon"; import { TradingIcon } from "../../assets/Icons/TradingIcon"; import { MessagingIcon } from "../../assets/Icons/MessagingIcon"; -import { HomeIcon } from "../../assets/Icons/HomeIcon"; +import AppIcon from "../../assets/svgs/AppIcon.svg"; -const IconWrapper = ({ children, label, color, selected }) => { +import { HomeIcon } from "../../assets/Icons/HomeIcon"; +import { Save } from "../Save/Save"; + +export const IconWrapper = ({ children, label, color, selected }) => { return ( {children} @@ -69,9 +72,17 @@ export const DesktopFooter = ({ isHome, isGroups, isDirects, - setDesktopSideView + setDesktopSideView, + isApps, + setDesktopViewMode, + desktopViewMode, + hide, + setIsOpenSideViewDirects, + setIsOpenSideViewGroups + }) => { - const [value, setValue] = React.useState(0); + + if(hide) return return ( - - { - goToHome() - }}> - - - - - { - setDesktopSideView('groups') - }}> - - - - - { - setDesktopSideView('directs') - }}> - - - - - - - + + { + goToHome(); + }} + > + + + + + { + setDesktopViewMode('apps') + setIsOpenSideViewDirects(false) + setIsOpenSideViewGroups(false) + }} + > + + + + + { + setDesktopSideView("groups"); + }} + > + + + + + { + setDesktopSideView("directs"); + }} + > + + + + + + + ); }; diff --git a/src/components/Drawer/Drawer.tsx b/src/components/Drawer/Drawer.tsx index 2e545cd..0296088 100644 --- a/src/components/Drawer/Drawer.tsx +++ b/src/components/Drawer/Drawer.tsx @@ -11,6 +11,7 @@ import ListItemText from '@mui/material/ListItemText'; import InboxIcon from '@mui/icons-material/MoveToInbox'; import MailIcon from '@mui/icons-material/Mail'; import CloseIcon from '@mui/icons-material/Close'; +import { isMobile } from '../../App'; export const DrawerComponent = ({open, setOpen, children}) => { const toggleDrawer = (newOpen: boolean) => () => { @@ -21,7 +22,7 @@ export const DrawerComponent = ({open, setOpen, children}) => { return (
- + {children} diff --git a/src/components/Group/Forum/GroupMail.tsx b/src/components/Group/Forum/GroupMail.tsx index 05a1b3e..36fefad 100644 --- a/src/components/Group/Forum/GroupMail.tsx +++ b/src/components/Group/Forum/GroupMail.tsx @@ -536,10 +536,14 @@ export const GroupMail = ({ }); // Convert the map back to an array and sort by "created" timestamp in descending order - const sortedList = Array.from(uniqueItems.values()).sort((a, b) => b.threadData?.createdAt - a.threadData?.createdAt); + const sortedList = Array.from(uniqueItems.values()).sort((a, b) => + filterMode === 'Oldest' + ? a.threadData?.createdAt - b.threadData?.createdAt + : b.threadData?.createdAt - a.threadData?.createdAt +); return sortedList; - }, [tempPublishedList, listOfThreadsToDisplay]); + }, [tempPublishedList, listOfThreadsToDisplay, filterMode]); if (currentThread) return ( diff --git a/src/components/Group/Forum/Mail-styles.ts b/src/components/Group/Forum/Mail-styles.ts index 2d39cee..5308bf0 100644 --- a/src/components/Group/Forum/Mail-styles.ts +++ b/src/components/Group/Forum/Mail-styles.ts @@ -754,29 +754,7 @@ export const GroupContainer = styled(Box)` position: relative; overflow: auto; width: 100%; -&::-webkit-scrollbar-track { - background-color: transparent; -} -&::-webkit-scrollbar-track:hover { - background-color: transparent; -} -&::-webkit-scrollbar { - width: 16px; - height: 10px; - background-color: white; -} - -&::-webkit-scrollbar-thumb { - background-color: #838eee; - border-radius: 8px; - background-clip: content-box; - border: 4px solid transparent; -} - -&::-webkit-scrollbar-thumb:hover { - background-color: #6270f0; -} ` diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 45d9c57..9206914 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -88,6 +88,9 @@ import { ExitIcon } from "../../assets/Icons/ExitIcon"; import { HomeDesktop } from "./HomeDesktop"; import { DesktopFooter } from "../Desktop/DesktopFooter"; import { DesktopHeader } from "../Desktop/DesktopHeader"; +import { Apps } from "../Apps/Apps"; +import { AppsNavBar } from "../Apps/AppsNavBar"; +import { AppsDesktop } from "../Apps/AppsDesktop"; // let touchStartY = 0; // let disablePullToRefresh = false; @@ -373,7 +376,11 @@ export const Group = ({ isOpenDrawerProfile, setIsOpenDrawerProfile, logoutFunc, + setDesktopViewMode, + desktopViewMode }: GroupProps) => { + const [desktopSideView, setDesktopSideView] = useState('groups') + const [secretKey, setSecretKey] = useState(null); const [secretKeyPublishDate, setSecretKeyPublishDate] = useState(null); const lastFetchedSecretKey = useRef(null); @@ -418,7 +425,6 @@ export const Group = ({ const [mutedGroups, setMutedGroups] = useState([]); const [mobileViewMode, setMobileViewMode] = useState("home"); const [mobileViewModeKeepOpen, setMobileViewModeKeepOpen] = useState(""); - const [desktopSideView, setDesktopSideView] = useState('groups') const isFocusedRef = useRef(true); const timestampEnterDataRef = useRef({}); const selectedGroupRef = useRef(null); @@ -431,7 +437,22 @@ export const Group = ({ const { clearStatesMessageQueueProvider } = useMessageQueue(); const initiatedGetMembers = useRef(false); const [groupChatTimestamps, setGroupChatTimestamps] = React.useState({}); + const [appsMode, setAppsMode] = useState('home') + const [isOpenSideViewDirects, setIsOpenSideViewDirects] = useState(false) + const [isOpenSideViewGroups, setIsOpenSideViewGroups] = useState(false) + const toggleSideViewDirects = ()=> { + if(isOpenSideViewGroups){ + setIsOpenSideViewGroups(false) + } + setIsOpenSideViewDirects((prev)=> !prev) + } + const toggleSideViewGroups = ()=> { + if(isOpenSideViewDirects){ + setIsOpenSideViewDirects(false) + } + setIsOpenSideViewGroups((prev)=> !prev) + } useEffect(()=> { timestampEnterDataRef.current = timestampEnterData }, [timestampEnterData]) @@ -821,98 +842,7 @@ export const Group = ({ } }, [selectedGroup]); - // const handleNotification = async (data)=> { - // try { - // if(isFocusedRef.current){ - // throw new Error('isFocused') - // } - // const newActiveChats= data - // const oldActiveChats = await new Promise((res, rej) => { - // chrome?.runtime?.sendMessage( - // { - // action: "getChatHeads", - // }, - // (response) => { - // console.log({ response }); - // if (!response?.error) { - // res(response); - // } - // rej(response.error); - // } - // ); - // }); - - // let results = [] - // newActiveChats?.groups?.forEach(newChat => { - // let isNewer = true; - // oldActiveChats?.data?.groups?.forEach(oldChat => { - // if (newChat?.timestamp <= oldChat?.timestamp) { - // isNewer = false; - // } - // }); - // if (isNewer) { - // results.push(newChat) - // console.log('This newChat is newer than all oldChats:', newChat); - // } - // }); - - // if(results?.length > 0){ - // if (!lastGroupNotification.current || (Date.now() - lastGroupNotification.current >= 60000)) { - // console.log((Date.now() - lastGroupNotification.current >= 60000), lastGroupNotification.current) - // chrome?.runtime?.sendMessage( - // { - // action: "notification", - // payload: { - // }, - // }, - // (response) => { - // console.log({ response }); - // if (!response?.error) { - - // } - - // } - // ); - // audio.play(); - // lastGroupNotification.current = Date.now() - - // } - // } - - // } catch (error) { - // console.log('error not', error) - // if(!isFocusedRef.current){ - // chrome?.runtime?.sendMessage( - // { - // action: "notification", - // payload: { - // }, - // }, - // (response) => { - // console.log({ response }); - // if (!response?.error) { - - // } - - // } - // ); - // audio.play(); - // lastGroupNotification.current = Date.now() - // } - - // } finally { - - // chrome?.runtime?.sendMessage( - // { - // action: "setChatHeads", - // payload: { - // data, - // }, - // } - // ); - - // } - // } + const getAdmins = async (groupId) => { try { @@ -1176,6 +1106,7 @@ export const Group = ({ if (findDirect) { if(!isMobile){ setDesktopSideView("directs"); + setDesktopViewMode('home') } else { setMobileViewModeKeepOpen("messaging"); } @@ -1213,6 +1144,7 @@ export const Group = ({ if (findDirect) { if(!isMobile){ setDesktopSideView("directs"); + setDesktopViewMode('home') } else { setMobileViewModeKeepOpen("messaging"); } @@ -1236,6 +1168,7 @@ export const Group = ({ } else { if(!isMobile){ setDesktopSideView("directs"); + setDesktopViewMode('home') } else { setMobileViewModeKeepOpen("messaging"); } @@ -1402,6 +1335,8 @@ export const Group = ({ setTimeout(() => { setSelectedGroup(findGroup); setMobileViewMode("group"); + setDesktopSideView('groups') + setDesktopViewMode('home') getTimestampEnterChat(); isLoadingOpenSectionFromNotification.current = false; }, 200); @@ -1449,7 +1384,8 @@ export const Group = ({ setTimeout(() => { setSelectedGroup(findGroup); setMobileViewMode("group"); - + setDesktopSideView('groups') + setDesktopViewMode('home') getGroupAnnouncements(); }, 200); } @@ -1504,6 +1440,8 @@ export const Group = ({ setTimeout(() => { setSelectedGroup(findGroup); setMobileViewMode("group"); + setDesktopSideView('groups') + setDesktopViewMode('home') getGroupAnnouncements(); }, 200); } @@ -1527,6 +1465,8 @@ export const Group = ({ } if (!isMobile) { } + setDesktopViewMode('home') + setGroupSection("default"); clearAllQueues(); await new Promise((res) => { @@ -1550,6 +1490,8 @@ export const Group = ({ setMemberCountFromSecretKeyData(null); setTriedToFetchSecretKey(false); setFirstSecretKeyInCreation(false); + setIsOpenSideViewDirects(false) + setIsOpenSideViewGroups(false) }; const goToAnnouncements = async () => { @@ -2025,6 +1967,8 @@ export const Group = ({ // } onClick={() => { setMobileViewMode("group"); + setDesktopSideView('groups') + setDesktopViewMode('home') initiatedGetMembers.current = false; clearAllQueues(); setSelectedDirect(null); @@ -2228,7 +2172,7 @@ export const Group = ({ isThin={ mobileViewMode === "groups" || mobileViewMode === "group" || - mobileViewModeKeepOpen === "messaging" + mobileViewModeKeepOpen === "messaging" || (mobileViewMode === "apps" && appsMode !== 'home') } logoutFunc={logoutFunc} goToHome={goToHome} @@ -2253,8 +2197,8 @@ export const Group = ({ alignItems: "flex-start", }} > - {!isMobile && desktopSideView === 'groups' && renderGroups()} - {!isMobile && desktopSideView === 'directs' && renderDirects()} + {!isMobile && ((desktopSideView === 'groups' && desktopViewMode !== 'apps') || isOpenSideViewGroups) && renderGroups()} + {!isMobile && ((desktopSideView === 'directs' && desktopViewMode !== 'apps') || isOpenSideViewDirects) && renderDirects()} )} {isMobile && mobileViewMode === "home" && ( @@ -2733,11 +2683,19 @@ export const Group = ({ setMobileViewMode={setMobileViewMode} /> )} - { - !isMobile && !selectedGroup && - groupSection === "home" && ( - - + )} + {!isMobile && ( + + )} + + + {!isMobile && !selectedGroup && + groupSection === "home" && desktopViewMode !== "apps" && ( + - )} + )} + + - {/* - - - - Home - - - {selectedGroup && ( - <> - - - - - Announcements - - - - - - - - Chat - - - - - { - setGroupSection("forum"); - setSelectedDirect(null); - setNewChat(false); - }} - > - - - Forum - - - - setOpenManageMembers(true)} - sx={{ - display: "flex", - gap: "3px", - alignItems: "center", - justifyContent: "flex-start", - width: "100%", - cursor: "pointer", - }} - > - - - Members - - - - - )} */} - - {/* */} +
- {isMobile && mobileViewMode === "home" && !mobileViewModeKeepOpen && ( + {(isMobile && mobileViewMode === "home" || (isMobile && mobileViewMode === "apps" && appsMode === 'home')) && !mobileViewModeKeepOpen && ( <>
)} + {(isMobile && mobileViewMode === "apps" && appsMode !== 'home') && !mobileViewModeKeepOpen && ( + <> + + + )} ); }; -// {isMobile && ( -// -// -// {selectedGroup && ( -// <> -// -// -// -// -// -// -// -// -// -// -// -// -// -// )} -// {/* Second row: Groups, Home, Profile */} -// -// -// -// -// -// -// -// -// -// setIsOpenDrawerProfile(true)} -// > -// -// -// -// -// -// )} diff --git a/src/components/Group/GroupJoinRequests.tsx b/src/components/Group/GroupJoinRequests.tsx index d373435..47fcb31 100644 --- a/src/components/Group/GroupJoinRequests.tsx +++ b/src/components/Group/GroupJoinRequests.tsx @@ -48,20 +48,7 @@ export const GroupJoinRequests = ({ myAddress, groups, setOpenManageMembers, get return true }) - // const getJoinGroupRequests = groupsAsAdmin.map(async (group)=> { - // console.log('getJoinGroupRequests', group) - // const joinRequestResponse = await requestQueueGroupJoinRequests.enqueue(()=> { - // return fetch( - // `${getBaseApiReact()}/groups/joinrequests/${group.groupId}` - // ); - // }) - - // const joinRequestData = await joinRequestResponse.json() - // return { - // group, - // data: joinRequestData - // } - // }) + await Promise.all(getAllGroupsAsAdmin) const res = await Promise.all(groupsAsAdmin.map(async (group)=> { diff --git a/src/components/Group/GroupMenu.tsx b/src/components/Group/GroupMenu.tsx index 5aff12e..989c7de 100644 --- a/src/components/Group/GroupMenu.tsx +++ b/src/components/Group/GroupMenu.tsx @@ -134,6 +134,7 @@ export const GroupMenu = ({ setGroupSection, groupSection, setOpenManageMembers, "& .MuiTypography-root": { fontSize: "12px", fontWeight: 600, + color: hasUnreadChat ? "var(--unread)" :"#fff" }, }} primary="Chat" /> @@ -153,6 +154,7 @@ export const GroupMenu = ({ setGroupSection, groupSection, setOpenManageMembers, "& .MuiTypography-root": { fontSize: "12px", fontWeight: 600, + color: hasUnreadAnnouncements ? "var(--unread)" :"#fff" }, }} primary="Announcements" /> diff --git a/src/components/Mobile/MobileFooter.tsx b/src/components/Mobile/MobileFooter.tsx index ca31331..7cb3a32 100644 --- a/src/components/Mobile/MobileFooter.tsx +++ b/src/components/Mobile/MobileFooter.tsx @@ -2,11 +2,14 @@ import * as React from "react"; import { BottomNavigation, BottomNavigationAction, + ButtonBase, Typography, } from "@mui/material"; import { Home, Groups, Message, ShowChart } from "@mui/icons-material"; import Box from "@mui/material/Box"; import BottomLogo from "../../assets/svgs/BottomLogo5.svg"; +import LogoSelected from "../../assets/svgs/LogoSelected.svg"; + import { CustomSvg } from "../../common/CustomSvg"; import { WalletIcon } from "../../assets/Icons/WalletIcon"; import { HubsIcon } from "../../assets/Icons/HubsIcon"; @@ -132,6 +135,15 @@ export const MobileFooter = ({ zIndex: 3, }} > + { + if(mobileViewMode === 'home'){ + setMobileViewMode('apps') + + } else { + setMobileViewMode('home') + + } + }}> {/* Custom Center Icon */} - center-icon + center-icon + { const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); - + const [fullScreen, setFullScreen] = useRecoilState(fullScreenAtom); + const {exitFullScreen} = useAppFullScreen(setFullScreen) const handleClick = (event) => { setAnchorEl(event.currentTarget); }; @@ -76,10 +76,10 @@ const Header = ({ width: "75px", }} > - { setMobileViewModeKeepOpen(""); goToHome(); @@ -87,15 +87,24 @@ const Header = ({ // onClick={onHomeClick} > - - + - + + {fullScreen && ( + { + exitFullScreen() + setFullScreen(false) + }}> + + + )} + {/* Center Title */} @@ -121,34 +130,25 @@ const Header = ({ > {/* Right Logout Icon */} - { setMobileViewModeKeepOpen("messaging"); }} - edge="end" - color="inherit" - aria-label="logout" - - // onClick={onLogoutClick} > - - + + - + @@ -247,16 +247,32 @@ const Header = ({ }} > {/* Left Home Icon */} - + - - + + {fullScreen && ( + { + exitFullScreen() + setFullScreen(false) + }}> + + + )} + {/* Center Title */} QORTAL - + {/* Right Logout Icon */} - + - + + diff --git a/src/components/Save/Save.tsx b/src/components/Save/Save.tsx new file mode 100644 index 0000000..e00bd08 --- /dev/null +++ b/src/components/Save/Save.tsx @@ -0,0 +1,161 @@ +import React, { useContext, useEffect, useMemo, useState } from 'react' +import { useRecoilState, useSetRecoilState } from 'recoil'; +import isEqual from 'lodash/isEqual'; // Import deep comparison utility +import { canSaveSettingToQdnAtom, hasSettingsChangedAtom, oldPinnedAppsAtom, settingsLocalLastUpdatedAtom, settingsQDNLastUpdatedAtom, sortablePinnedAppsAtom } from '../../atoms/global'; +import { ButtonBase } from '@mui/material'; +import { objectToBase64 } from '../../qdn/encryption/group-encryption'; +import { MyContext } from '../../App'; +import { getFee } from '../../background'; +import { CustomizedSnackbars } from '../Snackbar/Snackbar'; +import { SaveIcon } from '../../assets/svgs/SaveIcon'; +import { IconWrapper } from '../Desktop/DesktopFooter'; +export const Save = ({isDesktop}) => { + const [pinnedApps, setPinnedApps] = useRecoilState(sortablePinnedAppsAtom); + const [settingsQdnLastUpdated, setSettingsQdnLastUpdated] = useRecoilState(settingsQDNLastUpdatedAtom); + const [settingsLocalLastUpdated] = useRecoilState(settingsLocalLastUpdatedAtom); + const setHasSettingsChangedAtom = useSetRecoilState(hasSettingsChangedAtom); + + const [canSave] = useRecoilState(canSaveSettingToQdnAtom); + const [openSnack, setOpenSnack] = useState(false); + const [isLoading, setIsLoading] = useState(false) + const [infoSnack, setInfoSnack] = useState(null); + const [oldPinnedApps, setOldPinnedApps] = useRecoilState(oldPinnedAppsAtom) + + const { show } = useContext(MyContext); + + const hasChanged = useMemo(()=> { + const newChanges = { + sortablePinnedApps: pinnedApps.map((item)=> { + return { + name: item?.name, + service: item?.service + } + }) + } + const oldChanges = { + sortablePinnedApps: oldPinnedApps.map((item)=> { + return { + name: item?.name, + service: item?.service + } + }) + } + if(settingsQdnLastUpdated === -100) return false + return !isEqual(oldChanges, newChanges) && settingsQdnLastUpdated < settingsLocalLastUpdated + }, [oldPinnedApps, pinnedApps, settingsQdnLastUpdated, settingsLocalLastUpdated]) + + useEffect(()=> { + setHasSettingsChangedAtom(hasChanged) + }, [hasChanged]) + + const saveToQdn = async ()=> { + try { + setIsLoading(true) + const data64 = await objectToBase64({ + sortablePinnedApps: pinnedApps.map((item)=> { + return { + name: item?.name, + service: item?.service + } + }) + }) + const encryptData = await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "ENCRYPT_DATA", + type: "qortalRequest", + payload: { + data64 + }, + }, + (response) => { + if (response.error) { + rej(response?.message); + return; + } else { + res(response); + + } + } + ); + }); + if(encryptData && !encryptData?.error){ + const fee = await getFee('ARBITRARY') + + await show({ + message: "Would you like to publish your settings to QDN (encrypted) ?" , + publishFee: fee.fee + ' QORT' + }) + const response = await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "publishOnQDN", + payload: { + data: encryptData, + identifier: "ext_saved_settings", + service: 'DOCUMENT_PRIVATE' + }, + }, + (response) => { + + if (!response?.error) { + res(response); + return + } + rej(response.error); + } + ); + }); + if(response?.identifier){ + setOldPinnedApps(pinnedApps) + setSettingsQdnLastUpdated(Date.now()) + setInfoSnack({ + type: "success", + message: + "Sucessfully published to QDN", + }); + setOpenSnack(true); + } + } + } catch (error) { + setInfoSnack({ + type: "error", + message: + error?.message || "Unable to save to QDN", + }); + setOpenSnack(true); + } finally { + setIsLoading(false) + } + } + return ( + <> + + {isDesktop ? ( + + + + ) : ( + + )} + + + + + + ) +} diff --git a/src/components/Snackbar/Snackbar.tsx b/src/components/Snackbar/Snackbar.tsx index a909487..3e5fccb 100644 --- a/src/components/Snackbar/Snackbar.tsx +++ b/src/components/Snackbar/Snackbar.tsx @@ -3,7 +3,7 @@ import Button from '@mui/material/Button'; import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; -export const CustomizedSnackbars = ({open, setOpen, info, setInfo}) => { +export const CustomizedSnackbars = ({open, setOpen, info, setInfo, duration}) => { @@ -19,9 +19,10 @@ export const CustomizedSnackbars = ({open, setOpen, info, setInfo}) => { setInfo(null) }; + if(!open) return null return (
- + + + , diff --git a/src/qdn/encryption/group-encryption.ts b/src/qdn/encryption/group-encryption.ts index 1905d4d..4f61dd8 100644 --- a/src/qdn/encryption/group-encryption.ts +++ b/src/qdn/encryption/group-encryption.ts @@ -67,7 +67,7 @@ export const createSymmetricKeyAndNonce = () => { export const encryptDataGroup = ({ data64, publicKeys, privateKey, userPublicKey }: any) => { - let combinedPublicKeys = publicKeys + let combinedPublicKeys = [...publicKeys, userPublicKey] const decodedPrivateKey = Base58.decode(privateKey) const publicKeysDuplicateFree = [...new Set(combinedPublicKeys)] @@ -275,11 +275,61 @@ export const decodeBase64ForUIChatMessages = (messages)=> { - + export function decryptGroupDataQortalRequest(data64EncryptedData, privateKey) { + 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 + const senderPublicKey = allCombined.slice(senderPublicKeyStartPosition, senderPublicKeyEndPosition) + // 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) + // Extract the encrypted keys + // 32+16 = 48 + const combinedKeys = allCombined.slice(encryptedDataEndPosition, encryptedDataEndPosition + (count * 48)) + if (!privateKey) { + throw new Error("Unable to retrieve keys") + } + const decodedPrivateKey = Base58.decode(privateKey) + const convertedPrivateKey = ed2curve.convertSecretKey(decodedPrivateKey) + const convertedSenderPublicKey = ed2curve.convertPublicKey(senderPublicKey) + const sharedSecret = new Uint8Array(32) + nacl.lowlevel.crypto_scalarmult(sharedSecret, convertedPrivateKey, convertedSenderPublicKey) + for (let i = 0; i < count; i++) { + const encryptedKey = combinedKeys.slice(i * 48, (i + 1) * 48) + // Decrypt the symmetric key. + const decryptedKey = nacl.secretbox.open(encryptedKey, keyNonce, sharedSecret) + // If decryption was successful, decryptedKey will not be null. + if (decryptedKey) { + // Decrypt the data using the symmetric key. + const decryptedData = nacl.secretbox.open(encryptedData, nonce, decryptedKey) + // If decryption was successful, decryptedData will not be null. + if (decryptedData) { + return decryptedData + } + } + } + throw new Error("Unable to decrypt data") +} export function decryptGroupData(data64EncryptedData: string, privateKey: string) { - const allCombined = base64ToUint8Array(data64EncryptedData) const str = "qortalGroupEncryptedData" const strEncoder = new TextEncoder() @@ -331,4 +381,43 @@ export function decryptGroupData(data64EncryptedData: string, privateKey: string } } throw new Error("Unable to decrypt data") +} + +export function uint8ArrayStartsWith(uint8Array, string) { + const stringEncoder = new TextEncoder() + const stringUint8Array = stringEncoder.encode(string) + if (uint8Array.length < stringUint8Array.length) { + return false + } + for (let i = 0; i < stringUint8Array.length; i++) { + if (uint8Array[i] !== stringUint8Array[i]) { + return false + } + } + return true +} + +export function decryptDeprecatedSingle(uint8Array, publicKey, privateKey) { + const combinedData = uint8Array + const str = "qortalEncryptedData" + const strEncoder = new TextEncoder() + const strUint8Array = strEncoder.encode(str) + const strData = combinedData.slice(0, strUint8Array.length) + const nonce = combinedData.slice(strUint8Array.length, strUint8Array.length + 24) + const _encryptedData = combinedData.slice(strUint8Array.length + 24) + + const _publicKey = window.parent.Base58.decode(publicKey) + if (!privateKey || !_publicKey) { + throw new Error("Unable to retrieve keys") + } + const convertedPrivateKey = ed2curve.convertSecretKey(privateKey) + const convertedPublicKey = ed2curve.convertPublicKey(_publicKey) + const sharedSecret = new Uint8Array(32) + nacl.lowlevel.crypto_scalarmult(sharedSecret, convertedPrivateKey, convertedPublicKey) + const _chatEncryptionSeed = new window.parent.Sha256().process(sharedSecret).finish().result + const _decryptedData = nacl.secretbox.open(_encryptedData, nonce, _chatEncryptionSeed) + if (!_decryptedData) { + throw new Error("Unable to decrypt") + } + return uint8ArrayToBase64(_decryptedData) } \ No newline at end of file diff --git a/src/qdn/publish/pubish.ts b/src/qdn/publish/pubish.ts index f359bba..a57fd1e 100644 --- a/src/qdn/publish/pubish.ts +++ b/src/qdn/publish/pubish.ts @@ -153,7 +153,6 @@ export const publishData = async ({ fee = feeAmount } else if (withFee) { const res = await getArbitraryFee() - if (res.fee) { fee = res.fee } else { @@ -162,9 +161,8 @@ export const publishData = async ({ } let transactionBytes = await uploadData(registeredName, file, fee) - - if (transactionBytes.error) { - throw new Error(transactionBytes.message || 'Error when uploading') + if (!transactionBytes || transactionBytes.error) { + throw new Error(transactionBytes?.message || 'Error when uploading') } else if (transactionBytes.includes('Error 500 Internal Server Error')) { throw new Error('Error when uploading') } @@ -183,7 +181,7 @@ export const publishData = async ({ } const uploadData = async (registeredName: string, file:any, fee: number) => { - if (identifier != null && identifier.trim().length > 0) { + let postBody = '' let urlSuffix = '' @@ -211,8 +209,7 @@ export const publishData = async ({ } let uploadDataUrl = `/arbitrary/${service}/${registeredName}${urlSuffix}` - - if (identifier.trim().length > 0) { + if (identifier?.trim().length > 0) { uploadDataUrl = `/arbitrary/${service}/${registeredName}/${identifier}${urlSuffix}` } @@ -256,7 +253,7 @@ export const publishData = async ({ } return await reusablePost(uploadDataUrl, postBody) - } + } try { diff --git a/src/qortalRequests.ts b/src/qortalRequests.ts new file mode 100644 index 0000000..3174148 --- /dev/null +++ b/src/qortalRequests.ts @@ -0,0 +1,428 @@ +import { addForeignServer, addListItems, createPoll, 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"; + + + +// Promisify chrome.storage.local.get +function getLocalStorage(key) { + return new Promise((resolve, reject) => { + chrome.storage.local.get([key], function (result) { + if (chrome.runtime.lastError) { + return reject(chrome.runtime.lastError); + } + resolve(result[key]); + }); + }); + } + + // Promisify chrome.storage.local.set + function setLocalStorage(data) { + return new Promise((resolve, reject) => { + chrome.storage.local.set(data, function () { + if (chrome.runtime.lastError) { + return reject(chrome.runtime.lastError); + } + resolve(); + }); + }); + } + + + export async function setPermission(key, value) { + try { + // Get the existing qortalRequestPermissions object + const qortalRequestPermissions = (await getLocalStorage('qortalRequestPermissions')) || {}; + + // Update the permission + qortalRequestPermissions[key] = value; + + // Save the updated object back to storage + await setLocalStorage({ qortalRequestPermissions }); + + console.log('Permission set for', key); + } catch (error) { + console.error('Error setting permission:', error); + } + } + + export async function getPermission(key) { + try { + // Get the qortalRequestPermissions object from storage + const qortalRequestPermissions = (await getLocalStorage('qortalRequestPermissions')) || {}; + + // Return the value for the given key, or null if it doesn't exist + return qortalRequestPermissions[key] || null; + } catch (error) { + console.error('Error getting permission:', error); + return null; + } + } + + + // TODO: GET_FRIENDS_LIST + // NOT SURE IF TO IMPLEMENT: LINK_TO_QDN_RESOURCE, QDN_RESOURCE_DISPLAYED, SET_TAB_NOTIFICATIONS + +chrome?.runtime?.onMessage.addListener((request, sender, sendResponse) => { + if (request) { + const isFromExtension = request?.isExtension + switch (request.action) { + case "GET_USER_ACCOUNT": { + getUserAccount() + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: "Unable to get user account" }); + }); + + break; + } + case "ENCRYPT_DATA": { + const data = request.payload; + + encryptData(data, sender) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "DECRYPT_DATA": { + const data = request.payload; + + decryptData(data) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "GET_LIST_ITEMS": { + const data = request.payload; + + getListItems(data, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "ADD_LIST_ITEMS": { + const data = request.payload; + + addListItems(data, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "DELETE_LIST_ITEM": { + const data = request.payload; + + deleteListItems(data, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "PUBLISH_QDN_RESOURCE": { + const data = request.payload; + + publishQDNResource(data, sender, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "PUBLISH_MULTIPLE_QDN_RESOURCES": { + const data = request.payload; + + publishMultipleQDNResources(data, sender, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "VOTE_ON_POLL": { + const data = request.payload; + + voteOnPoll(data, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "CREATE_POLL": { + const data = request.payload; + + createPoll(data, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "SEND_CHAT_MESSAGE": { + const data = request.payload; + sendChatMessage(data, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "JOIN_GROUP": { + const data = request.payload; + + joinGroup(data, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "SAVE_FILE": { + const data = request.payload; + + saveFile(data, sender, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "DEPLOY_AT": { + const data = request.payload; + + deployAt(data, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "GET_USER_WALLET": { + const data = request.payload; + + getUserWallet(data, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "GET_WALLET_BALANCE": { + const data = request.payload; + + getWalletBalance(data, false, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + + case "GET_USER_WALLET_INFO": { + const data = request.payload; + + getUserWalletInfo(data, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "GET_CROSSCHAIN_SERVER_INFO": { + const data = request.payload; + + getCrossChainServerInfo(data) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + case "GET_TX_ACTIVITY_SUMMARY": { + const data = request.payload; + + getTxActivitySummary(data) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + + case "GET_FOREIGN_FEE": { + const data = request.payload; + + getForeignFee(data) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + + case "UPDATE_FOREIGN_FEE": { + const data = request.payload; + + updateForeignFee(data) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + + case "GET_SERVER_CONNECTION_HISTORY": { + const data = request.payload; + + getServerConnectionHistory(data) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + + case "SET_CURRENT_FOREIGN_SERVER": { + const data = request.payload; + + setCurrentForeignServer(data) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + + case "ADD_FOREIGN_SERVER": { + const data = request.payload; + + addForeignServer(data) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + + case "REMOVE_FOREIGN_SERVER": { + const data = request.payload; + + removeForeignServer(data) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + + case "GET_DAY_SUMMARY": { + const data = request.payload; + + getDaySummary(data) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + + case "SEND_COIN": { + const data = request.payload; + + sendCoin(data, isFromExtension) + .then((res) => { + sendResponse(res); + }) + .catch((error) => { + sendResponse({ error: error.message }); + }); + + break; + } + } + } + return true; +}); diff --git a/src/qortalRequests/get.ts b/src/qortalRequests/get.ts new file mode 100644 index 0000000..de91590 --- /dev/null +++ b/src/qortalRequests/get.ts @@ -0,0 +1,2463 @@ +import { + computePow, + createEndpoint, + getBalanceInfo, + getFee, + getKeyPair, + getLastRef, + getSaveWallet, + processTransactionVersion2, + removeDuplicateWindow, + signChatFunc, + joinGroup as joinGroupFunc, + sendQortFee, + sendCoin as sendCoinFunc, + isUsingLocal +} from "../background"; +import { getNameInfo } from "../backgroundFunctions/encryption"; +import { QORT_DECIMALS } from "../constants/constants"; +import Base58 from "../deps/Base58"; +import { + base64ToUint8Array, + decryptDeprecatedSingle, + decryptGroupDataQortalRequest, + encryptDataGroup, + uint8ArrayStartsWith, + uint8ArrayToBase64, +} from "../qdn/encryption/group-encryption"; +import { publishData } from "../qdn/publish/pubish"; +import { getPermission, setPermission } from "../qortalRequests"; +import { createTransaction } from "../transactions/transactions"; +import { mimeToExtensionMap } from "../utils/memeTypes"; + + +const btcFeePerByte = 0.00000100 +const ltcFeePerByte = 0.00000030 +const dogeFeePerByte = 0.00001000 +const dgbFeePerByte = 0.00000010 +const rvnFeePerByte = 0.00001125 + + +const _createPoll = async ({pollName, pollDescription, options}, isFromExtension) => { + const fee = await getFee("CREATE_POLL"); + + const resPermission = await getUserPermission({ + text1: "You are requesting to create the poll below:", + text2: `Poll: ${pollName}`, + text3: `Description: ${pollDescription}`, + text4: `Options: ${options?.join(", ")}`, + fee: fee.fee, + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const wallet = await getSaveWallet(); + const address = wallet.address0; + const resKeyPair = await getKeyPair(); + const parsedData = JSON.parse(resKeyPair); + const uint8PrivateKey = Base58.decode(parsedData.privateKey); + const uint8PublicKey = Base58.decode(parsedData.publicKey); + const keyPair = { + privateKey: uint8PrivateKey, + publicKey: uint8PublicKey, + }; + let lastRef = await getLastRef(); + + const tx = await createTransaction(8, keyPair, { + fee: fee.fee, + ownerAddress: address, + rPollName: pollName, + rPollDesc: pollDescription, + rOptions: options, + lastReference: lastRef, + }); + const signedBytes = Base58.encode(tx.signedBytes); + const res = await processTransactionVersion2(signedBytes); + if (!res?.signature) + throw new Error(res?.message || "Transaction was not able to be processed"); + return res; + } else { + throw new Error("User declined request"); + } +}; + +const _deployAt = async ( + {name, + description, + tags, + creationBytes, + amount, + assetId, + atType}, isFromExtension +) => { + const fee = await getFee("DEPLOY_AT"); + + const resPermission = await getUserPermission({ + text1: "Would you like to deploy this AT?", + text2: `Name: ${name}`, + text3: `Description: ${description}`, + fee: fee.fee, + }, isFromExtension); + + const { accepted } = resPermission; + + if (accepted) { + const wallet = await getSaveWallet(); + const address = wallet.address0; + const lastReference = await getLastRef(); + const resKeyPair = await getKeyPair(); + const parsedData = JSON.parse(resKeyPair); + const uint8PrivateKey = Base58.decode(parsedData.privateKey); + const uint8PublicKey = Base58.decode(parsedData.publicKey); + const keyPair = { + privateKey: uint8PrivateKey, + publicKey: uint8PublicKey, + }; + + const tx = await createTransaction(16, keyPair, { + fee: fee.fee, + rName: name, + rDescription: description, + rTags: tags, + rAmount: amount, + rAssetId: assetId, + rCreationBytes: creationBytes, + atType: atType, + lastReference: lastReference, + }); + + const signedBytes = Base58.encode(tx.signedBytes); + + const res = await processTransactionVersion2(signedBytes); + if (!res?.signature) + throw new Error( + res?.message || "Transaction was not able to be processed" + ); + return res; + } else { + throw new Error("User declined transaction"); + } +}; + +const _voteOnPoll = async ({pollName, optionIndex, optionName}, isFromExtension) => { + const fee = await getFee("VOTE_ON_POLL"); + + const resPermission = await getUserPermission({ + text1: "You are being requested to vote on the poll below:", + text2: `Poll: ${pollName}`, + text3: `Option: ${optionName}`, + fee: fee.fee, + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const wallet = await getSaveWallet(); + const address = wallet.address0; + const resKeyPair = await getKeyPair(); + const parsedData = JSON.parse(resKeyPair); + const uint8PrivateKey = Base58.decode(parsedData.privateKey); + const uint8PublicKey = Base58.decode(parsedData.publicKey); + const keyPair = { + privateKey: uint8PrivateKey, + publicKey: uint8PublicKey, + }; + let lastRef = await getLastRef(); + + const tx = await createTransaction(9, keyPair, { + fee: fee.fee, + voterAddress: address, + rPollName: pollName, + rOptionIndex: optionIndex, + lastReference: lastRef, + }); + const signedBytes = Base58.encode(tx.signedBytes); + const res = await processTransactionVersion2(signedBytes); + if (!res?.signature) + throw new Error(res?.message || "Transaction was not able to be processed"); + return res; + } else { + throw new Error("User declined request"); + } +}; + +function getFileFromContentScript(fileId, sender) { + return new Promise((resolve, reject) => { + chrome.tabs.sendMessage( + sender.tab.id, + { action: "getFileFromIndexedDB", fileId: fileId }, + (response) => { + if (response && response.result) { + resolve(response.result); + } else { + reject(response?.error || "Failed to retrieve file"); + } + } + ); + }); +} +function sendToSaveFilePicker(data, sender) { + + chrome.tabs.sendMessage(sender.tab.id, { + action: "SHOW_SAVE_FILE_PICKER", + data, + }); +} + +async function responseFromExtension() { + return new Promise((resolve) => { + + // Send message to the content script to check focus + chrome.runtime.sendMessage({ action: "QORTAL_REQUEST_PERMISSION", payloa }, (response) => { + + if (chrome.runtime.lastError) { + resolve(false); // Error occurred, assume not focused + } else { + resolve(response); // Resolve based on the response + } + }); + }); +} + +async function getUserPermission(payload: any, isFromExtension?: boolean) { + function waitForWindowReady(windowId) { + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + chrome.windows.get(windowId, (win) => { + if (chrome.runtime.lastError) { + clearInterval(checkInterval); // Stop polling if there's an error + resolve(false); + } else if (win.state === "normal" || win.state === "maximized") { + clearInterval(checkInterval); // Window is ready + resolve(true); + } + }); + }, 100); // Check every 100ms + }); + } + + if(isFromExtension){ + + + return new Promise((resolve) => { + // Set a timeout for 1 second + const timeout = setTimeout(() => { + resolve(false); + }, 30000); + + // Send message to the content script to check focus + chrome.runtime.sendMessage( + { action: "QORTAL_REQUEST_PERMISSION", payload, isFromExtension }, + (response) => { + if (response === undefined) return; + clearTimeout(timeout); // Clear the timeout if we get a response + + if (chrome.runtime.lastError) { + resolve(false); // Error occurred, assume not focused + } else { + resolve(response); // Resolve based on the response + } + } + ); + }); + } + await new Promise((res) => { + const popupUrl = chrome.runtime.getURL("index.html?secondary=true"); + chrome.windows.getAll( + { populate: true, windowTypes: ["popup"] }, + (windows) => { + // Attempt to find an existing popup window that has a tab with the correct URL + const existingPopup = windows.find( + (w) => + w.tabs && + w.tabs.some((tab) => tab.url && tab.url.startsWith(popupUrl)) + ); + if (existingPopup) { + // If the popup exists but is minimized or not focused, focus it + chrome.windows.update(existingPopup.id, { + focused: true, + state: "normal", + }); + res(null); + } else { + // No existing popup found, create a new one + chrome.system.display.getInfo((displays) => { + // Assuming the primary display is the first one (adjust logic as needed) + const primaryDisplay = displays[0]; + const screenWidth = primaryDisplay.bounds.width; + const windowHeight = 500; // Your window height + const windowWidth = 400; // Your window width + + // Calculate left position for the window to appear on the right of the screen + const leftPosition = screenWidth - windowWidth; + + // Calculate top position for the window, adjust as desired + const topPosition = + (primaryDisplay.bounds.height - windowHeight) / 2; + + chrome.windows.create( + { + url: popupUrl, + type: "popup", + width: windowWidth, + height: windowHeight, + left: leftPosition, + top: 0, + }, + async (newWindow) => { + removeDuplicateWindow(popupUrl); + await waitForWindowReady(newWindow.id); + + res(null); + } + ); + }); + } + } + ); + }); + + await new Promise((res) => { + setTimeout(() => { + chrome.runtime.sendMessage({ + action: "SET_COUNTDOWN", + payload: 30, + }); + res(true); + }, 1000); + }); + return new Promise((resolve) => { + // Set a timeout for 1 second + const timeout = setTimeout(() => { + resolve(false); + }, 30000); + + // Send message to the content script to check focus + chrome.runtime.sendMessage( + { action: "QORTAL_REQUEST_PERMISSION", payload }, + (response) => { + if (response === undefined) return; + clearTimeout(timeout); // Clear the timeout if we get a response + + if (chrome.runtime.lastError) { + resolve(false); // Error occurred, assume not focused + } else { + resolve(response); // Resolve based on the response + } + } + ); + }); +} + +export const getUserAccount = async () => { + try { + const wallet = await getSaveWallet(); + const address = wallet.address0; + const publicKey = wallet.publicKey; + return { + address, + publicKey, + }; + } catch (error) { + throw new Error("Unable to fetch user account"); + } +}; + +export const encryptData = 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 resKeyPair = await getKeyPair(); + const parsedData = JSON.parse(resKeyPair); + const privateKey = parsedData.privateKey; + const userPublicKey = parsedData.publicKey; + + const encryptDataResponse = encryptDataGroup({ + data64, + publicKeys: publicKeys, + privateKey, + userPublicKey, + }); + if (encryptDataResponse) { + return encryptDataResponse; + } else { + throw new Error("Unable to encrypt"); + } +}; +export const decryptData = async (data) => { + const { encryptedData, publicKey } = data; + + if (!encryptedData) { + throw new Error(`Missing fields: encryptedData`); + } + const resKeyPair = await getKeyPair(); + const parsedData = JSON.parse(resKeyPair); + const uint8PrivateKey = Base58.decode(parsedData.privateKey); + const uint8Array = base64ToUint8Array(encryptedData); + const startsWithQortalEncryptedData = uint8ArrayStartsWith( + uint8Array, + "qortalEncryptedData" + ); + if (startsWithQortalEncryptedData) { + if (!publicKey) { + throw new Error(`Missing fields: publicKey`); + } + + const decryptedDataToBase64 = decryptDeprecatedSingle( + uint8Array, + publicKey, + uint8PrivateKey + ); + return decryptedDataToBase64; + } + const startsWithQortalGroupEncryptedData = uint8ArrayStartsWith( + uint8Array, + "qortalGroupEncryptedData" + ); + if (startsWithQortalGroupEncryptedData) { + const decryptedData = decryptGroupDataQortalRequest( + encryptedData, + parsedData.privateKey + ); + const decryptedDataToBase64 = uint8ArrayToBase64(decryptedData); + return decryptedDataToBase64; + } + throw new Error("Unable to decrypt"); +}; + +export const getListItems = async (data, isFromExtension) => { + const localNodeAvailable = await isUsingLocal() + if(!localNodeAvailable) throw new Error('Please use your local node.') + const requiredFields = ["list_name"]; + 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 value = (await getPermission("qAPPAutoLists")) || false; + + let skip = false; + if (value) { + skip = true; + } + let resPermission; + let acceptedVar; + let checkbox1Var; + if (!skip) { + resPermission = await getUserPermission({ + text1: "Do you give this application permission to", + text2: "Access the list", + highlightedText: data.list_name, + checkbox1: { + value: value, + label: "Always allow lists to be retrieved automatically", + }, + }, isFromExtension); + const { accepted, checkbox1 } = resPermission; + acceptedVar = accepted; + checkbox1Var = checkbox1; + setPermission("qAPPAutoLists", checkbox1); + } + + if (acceptedVar || skip) { + const url = await createEndpoint(`/lists/${data.list_name}`); + const response = await fetch(url); + if (!response.ok) throw new Error("Failed to fetch"); + + const list = await response.json(); + return list; + } else { + throw new Error("User declined to share list"); + } +}; + +export const addListItems = async (data, isFromExtension) => { + const localNodeAvailable = await isUsingLocal() + if(!localNodeAvailable) throw new Error('Please use your local node.') + const requiredFields = ["list_name", "items"]; + 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 items = data.items; + const list_name = data.list_name; + + const resPermission = await getUserPermission({ + text1: "Do you give this application permission to", + text2: `Add the following to the list ${list_name}:`, + highlightedText: items.join(", "), + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const url = await createEndpoint(`/lists/${list_name}`); + const body = { + items: items, + }; + const bodyToString = JSON.stringify(body); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: bodyToString, + }); + + if (!response.ok) throw new Error("Failed to add to list"); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + return res; + } else { + throw new Error("User declined add to list"); + } +}; + +export const deleteListItems = async (data, isFromExtension) => { + const localNodeAvailable = await isUsingLocal() + if(!localNodeAvailable) throw new Error('Please use your local node.') + const requiredFields = ["list_name", "item"]; + 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 item = data.item; + const list_name = data.list_name; + + const resPermission = await getUserPermission({ + text1: "Do you give this application permission to", + text2: `Remove the following from the list ${list_name}:`, + highlightedText: item, + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const url = await createEndpoint(`/lists/${list_name}`); + const body = { + items: [item], + }; + const bodyToString = JSON.stringify(body); + const response = await fetch(url, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: bodyToString, + }); + + if (!response.ok) throw new Error("Failed to add to list"); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + return res; + } else { + throw new Error("User declined delete from list"); + } +}; + +export const publishQDNResource = async (data: any, sender, isFromExtension) => { + const requiredFields = ["service"]; + 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); + } + if (!data.fileId && !data.data64) { + throw new Error("No data or file was submitted"); + } + // Use "default" if user hasn't specified an identifer + const service = data.service; + const registeredName = await getNameInfo(); + const name = registeredName; + let identifier = data.identifier; + let data64 = data.data64; + const filename = data.filename; + const title = data.title; + const description = data.description; + const category = data.category; + const tag1 = data.tag1; + const tag2 = data.tag2; + const tag3 = data.tag3; + const tag4 = data.tag4; + const tag5 = data.tag5; + if (data.identifier == null) { + identifier = "default"; + } + if ( + data.encrypt && + (!data.publicKeys || + (Array.isArray(data.publicKeys) && data.publicKeys.length === 0)) + ) { + throw new Error("Encrypting data requires public keys"); + } + if (!data.encrypt && data.service.endsWith("_PRIVATE")) { + throw new Error("Only encrypted data can go into private services"); + } + if (data.fileId) { + data64 = await getFileFromContentScript(data.fileId, sender); + } + if (data.encrypt) { + try { + const resKeyPair = await getKeyPair() + const parsedData = JSON.parse(resKeyPair) + const privateKey = parsedData.privateKey + const userPublicKey = parsedData.publicKey + const encryptDataResponse = encryptDataGroup({ + data64, + publicKeys: data.publicKeys, + privateKey, + userPublicKey + }); + if (encryptDataResponse) { + data64 = encryptDataResponse; + } + } catch (error) { + throw new Error( + error.message || "Upload failed due to failed encryption" + ); + } + } + + const fee = await getFee("ARBITRARY"); + + const resPermission = await getUserPermission({ + text1: "Do you give this application permission to publish to QDN?", + text2: `service: ${service}`, + text3: `identifier: ${identifier || null}`, + highlightedText: `isEncrypted: ${!!data.encrypt}`, + fee: fee.fee, + }, isFromExtension); + const { accepted } = resPermission; + if (accepted) { + if (data.fileId && !data.encrypt) { + data64 = await getFileFromContentScript(data.fileId, sender); + } + try { + const resPublish = await publishData({ + registeredName: encodeURIComponent(name), + file: data64, + service: service, + identifier: encodeURIComponent(identifier), + uploadType: "file", + isBase64: true, + filename: filename, + title, + description, + category, + tag1, + tag2, + tag3, + tag4, + tag5, + apiVersion: 2, + withFee: true, + }); + return resPublish; + } catch (error) { + throw new Error(error?.message || "Upload failed"); + } + } else { + throw new Error("User declined request"); + } +}; + +export const publishMultipleQDNResources = async (data: any, sender, isFromExtension) => { + const requiredFields = ["resources"]; + const missingFields: string[] = []; + let feeAmount = null; + 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 resources = data.resources; + if (!Array.isArray(resources)) { + throw new Error("Invalid data"); + } + if (resources.length === 0) { + throw new Error("No resources to publish"); + } + if ( + data.encrypt && + (!data.publicKeys || + (Array.isArray(data.publicKeys) && data.publicKeys.length === 0)) + ) { + throw new Error("Encrypting data requires public keys"); + } + const fee = await getFee("ARBITRARY"); + const registeredName = await getNameInfo(); + const name = registeredName; + const resPermission = await getUserPermission({ + text1: "Do you give this application permission to publish to QDN?", + html: ` +
+ + + ${data.resources + .map( + (resource) => ` +
+
Service: ${ + resource.service + }
+
Name: ${resource.name}
+
Identifier: ${ + resource.identifier + }
+ ${ + resource.filename + ? `
Filename: ${resource.filename}
` + : "" + } +
` + ) + .join("")} +
+ + `, + highlightedText: `isEncrypted: ${!!data.encrypt}`, + fee: fee.fee * resources.length, + }, isFromExtension); + const { accepted } = resPermission; + if (!accepted) { + throw new Error("User declined request"); + } + let failedPublishesIdentifiers = []; + for (const resource of resources) { + try { + const requiredFields = ["service"]; + const missingFields: string[] = []; + requiredFields.forEach((field) => { + if (!resource[field]) { + missingFields.push(field); + } + }); + if (missingFields.length > 0) { + const missingFieldsString = missingFields.join(", "); + const errorMsg = `Missing fields: ${missingFieldsString}`; + failedPublishesIdentifiers.push({ + reason: errorMsg, + identifier: resource.identifier, + }); + continue; + } + if (!resource.fileId && !resource.data64) { + const errorMsg = "No data or file was submitted"; + failedPublishesIdentifiers.push({ + reason: errorMsg, + identifier: resource.identifier, + }); + continue; + } + const service = resource.service; + let identifier = resource.identifier; + let data64 = resource.data64; + const filename = resource.filename; + const title = resource.title; + const description = resource.description; + const category = resource.category; + const tag1 = resource.tag1; + const tag2 = resource.tag2; + const tag3 = resource.tag3; + const tag4 = resource.tag4; + const tag5 = resource.tag5; + if (resource.identifier == null) { + identifier = "default"; + } + if (!data.encrypt && service.endsWith("_PRIVATE")) { + const errorMsg = "Only encrypted data can go into private services"; + failedPublishesIdentifiers.push({ + reason: errorMsg, + identifier: resource.identifier, + }); + continue; + } + if (resource.fileId) { + data64 = await getFileFromContentScript(resource.fileId, sender); + } + if (data.encrypt) { + try { + const resKeyPair = await getKeyPair() + const parsedData = JSON.parse(resKeyPair) + const privateKey = parsedData.privateKey + const userPublicKey = parsedData.publicKey + const encryptDataResponse = encryptDataGroup({ + data64, + publicKeys: data.publicKeys, + privateKey, + userPublicKey + }); + if (encryptDataResponse) { + data64 = encryptDataResponse; + } + } catch (error) { + const errorMsg = + error?.message || "Upload failed due to failed encryption"; + failedPublishesIdentifiers.push({ + reason: errorMsg, + identifier: resource.identifier, + }); + continue; + } + } + + try { + await publishData({ + registeredName: encodeURIComponent(name), + file: data64, + service: service, + identifier: encodeURIComponent(identifier), + uploadType: "file", + isBase64: true, + filename: filename, + title, + description, + category, + tag1, + tag2, + tag3, + tag4, + tag5, + apiVersion: 2, + withFee: true, + }); + await new Promise((res) => { + setTimeout(() => { + res(); + }, 1000); + }); + } catch (error) { + const errorMsg = error.message || "Upload failed"; + failedPublishesIdentifiers.push({ + reason: errorMsg, + identifier: resource.identifier, + }); + } + } catch (error) { + failedPublishesIdentifiers.push({ + reason: "Unknown error", + identifier: resource.identifier, + }); + } + } + if (failedPublishesIdentifiers.length > 0) { + const obj = {}; + obj["error"] = { + unsuccessfulPublishes: failedPublishesIdentifiers, + }; + return obj; + } + return true; +}; + +export const voteOnPoll = async (data, isFromExtension) => { + const requiredFields = ["pollName", "optionIndex"]; + const missingFields: string[] = []; + requiredFields.forEach((field) => { + if (!data[field] && data[field] !== 0) { + missingFields.push(field); + } + }); + if (missingFields.length > 0) { + const missingFieldsString = missingFields.join(", "); + const errorMsg = `Missing fields: ${missingFieldsString}`; + throw new Error(errorMsg); + } + const pollName = data.pollName; + const optionIndex = data.optionIndex; + let pollInfo = null; + try { + const url = await createEndpoint(`/polls/${encodeURIComponent(pollName)}`); + const response = await fetch(url); + if (!response.ok) throw new Error("Failed to fetch poll"); + + pollInfo = await response.json(); + } catch (error) { + const errorMsg = (error && error.message) || "Poll not found"; + throw new Error(errorMsg); + } + if (!pollInfo || pollInfo.error) { + const errorMsg = (pollInfo && pollInfo.message) || "Poll not found"; + throw new Error(errorMsg); + } + try { + const optionName = pollInfo.pollOptions[optionIndex].optionName; + const resVoteOnPoll = await _voteOnPoll({pollName, optionIndex, optionName}, isFromExtension); + return resVoteOnPoll; + } catch (error) { + throw new Error(error?.message || "Failed to vote on the poll."); + } +}; + +export const createPoll = async (data, isFromExtension) => { + const requiredFields = [ + "pollName", + "pollDescription", + "pollOptions", + "pollOwnerAddress", + ]; + 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 pollName = data.pollName; + const pollDescription = data.pollDescription; + const pollOptions = data.pollOptions; + const pollOwnerAddress = data.pollOwnerAddress; + try { + const resCreatePoll = await _createPoll( + { + pollName, + pollDescription, + options: pollOptions, + }, + isFromExtension + ); + return resCreatePoll; + } catch (error) { + throw new Error(error?.message || "Failed to created poll."); + } +}; + +export const sendChatMessage = async (data, isFromExtension) => { + const message = data.message; + const recipient = data.destinationAddress; + const groupId = data.groupId; + const isRecipient = !groupId; + const resPermission = await getUserPermission({ + text1: "Do you give this application permission to send this chat message?", + text2: `To: ${isRecipient ? recipient : `group ${groupId}`}`, + text3: `${message?.slice(0, 25)}${message?.length > 25 ? "..." : ""}`, + }, isFromExtension); + + const { accepted } = resPermission; + if (accepted) { + const tiptapJson = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: message, + }, + ], + }, + ], + }; + const messageObject = { + messageText: tiptapJson, + images: [""], + repliedTo: "", + version: 3, + }; + + const stringifyMessageObject = JSON.stringify(messageObject); + + const balance = await getBalanceInfo(); + const hasEnoughBalance = +balance < 4 ? false : true; + if (!hasEnoughBalance) { + throw new Error("You need at least 4 QORT to send a message"); + } + if (isRecipient && recipient) { + const url = await createEndpoint(`/addresses/publickey/${recipient}`); + const response = await fetch(url); + if (!response.ok) + throw new Error("Failed to fetch recipient's public key"); + + let key; + let hasPublicKey; + let res; + const contentType = response.headers.get("content-type"); + + // If the response is JSON, parse it as JSON + if (contentType && contentType.includes("application/json")) { + res = await response.json(); + } else { + // Otherwise, treat it as plain text + res = await response.text(); + } + if (res?.error === 102) { + key = ""; + hasPublicKey = false; + } else if (res !== false) { + key = res; + hasPublicKey = true; + } else { + key = ""; + hasPublicKey = false; + } + + if (!hasPublicKey && isRecipient) { + throw new Error( + "Cannot send an encrypted message to this user since they do not have their publickey on chain." + ); + } + let _reference = new Uint8Array(64); + self.crypto.getRandomValues(_reference); + + let sendTimestamp = Date.now(); + + let reference = Base58.encode(_reference); + const resKeyPair = await getKeyPair(); + const parsedData = JSON.parse(resKeyPair); + const uint8PrivateKey = Base58.decode(parsedData.privateKey); + const uint8PublicKey = Base58.decode(parsedData.publicKey); + const keyPair = { + privateKey: uint8PrivateKey, + publicKey: uint8PublicKey, + }; + + const difficulty = 8; + const tx = await createTransaction(18, keyPair, { + timestamp: sendTimestamp, + recipient: recipient, + recipientPublicKey: key, + hasChatReference: 0, + message: stringifyMessageObject, + lastReference: reference, + proofOfWorkNonce: 0, + isEncrypted: 1, + isText: 1, + }); + const path = chrome.runtime.getURL("memory-pow.wasm.full"); + + const { nonce, chatBytesArray } = await computePow({ + chatBytes: tx.chatBytes, + path, + difficulty, + }); + + let _response = await signChatFunc(chatBytesArray, nonce, null, keyPair); + if (_response?.error) { + throw new Error(_response?.message); + } + return _response; + } else if (!isRecipient && groupId) { + let _reference = new Uint8Array(64); + self.crypto.getRandomValues(_reference); + + let reference = Base58.encode(_reference); + const resKeyPair = await getKeyPair(); + const parsedData = JSON.parse(resKeyPair); + const uint8PrivateKey = Base58.decode(parsedData.privateKey); + const uint8PublicKey = Base58.decode(parsedData.publicKey); + const keyPair = { + privateKey: uint8PrivateKey, + publicKey: uint8PublicKey, + }; + + const difficulty = 8; + + const txBody = { + timestamp: Date.now(), + groupID: Number(groupId), + hasReceipient: 0, + hasChatReference: 0, + message: stringifyMessageObject, + lastReference: reference, + proofOfWorkNonce: 0, + isEncrypted: 0, // Set default to not encrypted for groups + isText: 1, + }; + + const tx = await createTransaction(181, keyPair, txBody); + + // if (!hasEnoughBalance) { + // throw new Error("Must have at least 4 QORT to send a chat message"); + // } + const path = chrome.runtime.getURL("memory-pow.wasm.full"); + + const { nonce, chatBytesArray } = await computePow({ + chatBytes: tx.chatBytes, + path, + difficulty, + }); + let _response = await signChatFunc(chatBytesArray, nonce, null, keyPair); + if (_response?.error) { + throw new Error(_response?.message); + } + return _response; + } else { + throw new Error("Please enter a recipient or groupId"); + } + } else { + throw new Error("User declined to send message"); + } +}; + +export const joinGroup = async (data, isFromExtension) => { + const requiredFields = ["groupId"]; + 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 groupInfo = null; + try { + const url = await createEndpoint(`/groups/${data.groupId}`); + const response = await fetch(url); + if (!response.ok) throw new Error("Failed to fetch group"); + + groupInfo = await response.json(); + } catch (error) { + const errorMsg = (error && error.message) || "Group not found"; + throw new Error(errorMsg); + } + const fee = await getFee("JOIN_GROUP"); + + const resPermission = await getUserPermission({ + text1: "Confirm joining the group:", + highlightedText: `${groupInfo.groupName}`, + fee: fee.fee, + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const groupId = data.groupId; + + if (!groupInfo || groupInfo.error) { + const errorMsg = (groupInfo && groupInfo.message) || "Group not found"; + throw new Error(errorMsg); + } + try { + const resJoinGroup = await joinGroupFunc({ groupId }); + return resJoinGroup; + } catch (error) { + throw new Error(error?.message || "Failed to join the group."); + } + } else { + throw new Error("User declined to join group"); + } +}; + +export const saveFile = async (data, sender, isFromExtension) => { + try { + const requiredFields = ["filename", "fileId"]; + 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 filename = data.filename; + const blob = data.blob; + const fileId = data.fileId; + const resPermission = await getUserPermission({ + text1: "Would you like to download:", + highlightedText: `${filename}`, + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const mimeType = blob.type || data.mimeType; + let backupExention = filename.split(".").pop(); + if (backupExention) { + backupExention = "." + backupExention; + } + const fileExtension = mimeToExtensionMap[mimeType] || backupExention; + let fileHandleOptions = {}; + if (!mimeType) { + throw new Error("A mimeType could not be derived"); + } + if (!fileExtension) { + const obj = {}; + throw new Error("A file extension could not be derived"); + } + if (fileExtension && mimeType) { + fileHandleOptions = { + accept: { + [mimeType]: [fileExtension], + }, + }; + } + sendToSaveFilePicker( + { + filename, + mimeType, + blob, + fileId, + fileHandleOptions, + }, + sender + ); + return true; + } else { + throw new Error("User declined to save file"); + } + } catch (error) { + throw new Error(error?.message || "Failed to initiate download"); + } +}; + +export const deployAt = async (data, isFromExtension) => { + const requiredFields = [ + "name", + "description", + "tags", + "creationBytes", + "amount", + "assetId", + "type", + ]; + const missingFields: string[] = []; + requiredFields.forEach((field) => { + if (!data[field] && data[field] !== 0) { + missingFields.push(field); + } + }); + if (missingFields.length > 0) { + const missingFieldsString = missingFields.join(", "); + const errorMsg = `Missing fields: ${missingFieldsString}`; + throw new Error(errorMsg); + } + try { + const resDeployAt = await _deployAt( + { + name: data.name, + description: data.description, + tags: data.tags, + creationBytes: data.creationBytes, + amount: data.amount, + assetId: data.assetId, + atType: data.type + }, + isFromExtension + ); + return resDeployAt; + } catch (error) { + throw new Error(error?.message || "Failed to join the group."); + } +}; + +export const getUserWallet = async (data, isFromExtension) => { + const requiredFields = ["coin"]; + 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 resPermission = await getUserPermission({ + text1: + "Do you give this application permission to get your wallet information?", + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + let coin = data.coin; + let userWallet = {}; + let arrrAddress = ""; + const wallet = await getSaveWallet(); + const address = wallet.address0; + const resKeyPair = await getKeyPair(); + const parsedData = JSON.parse(resKeyPair); + const arrrSeed58 = parsedData.arrrSeed58; + if (coin === "ARRR") { + const bodyToString = arrrSeed58; + const url = await createEndpoint(`/crosschain/arrr/walletaddress`); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: bodyToString, + }); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + if (res?.error && res?.message) { + throw new Error(res.message); + } + arrrAddress = res; + } + switch (coin) { + case "QORT": + userWallet["address"] = address; + userWallet["publickey"] = parsedData.publicKey; + break; + case "BTC": + userWallet["address"] = parsedData.btcAddress; + userWallet["publickey"] = parsedData.derivedMasterPublicKey; + break; + case "LTC": + userWallet["address"] = parsedData.ltcAddress; + userWallet["publickey"] = parsedData.ltcPublicKey; + break; + case "DOGE": + userWallet["address"] = parsedData.dogeAddress; + userWallet["publickey"] = parsedData.dogePublicKey; + break; + case "DGB": + userWallet["address"] = parsedData.dgbAddress; + userWallet["publickey"] = parsedData.dgbPublicKey; + break; + case "RVN": + userWallet["address"] = parsedData.rvnAddress; + userWallet["publickey"] = parsedData.rvnPublicKey; + break; + case "ARRR": + userWallet["address"] = arrrAddress; + break; + default: + break; + } + return userWallet; + } else { + throw new Error("User declined request"); + } +}; + +export const getWalletBalance = async (data, bypassPermission?: boolean, isFromExtension) => { + const requiredFields = ["coin"]; + 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 resPermission + + if(!bypassPermission){ + resPermission = await getUserPermission({ + text1: "Do you give this application permission to fetch your", + highlightedText: `${data.coin} balance`, + }, isFromExtension); + } else { + resPermission = { + accepted: false + } + } + + const { accepted } = resPermission; + + if (accepted || bypassPermission) { + let coin = data.coin; + const wallet = await getSaveWallet(); + const address = wallet.address0; + const resKeyPair = await getKeyPair(); + const parsedData = JSON.parse(resKeyPair); + if (coin === "QORT") { + let qortAddress = address; + try { + const url = await createEndpoint(`/addresses/balance/${qortAddress}`); + const response = await fetch(url); + if (!response.ok) throw new Error("Failed to fetch"); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + return res; + } catch (error) { + throw new Error( + error?.message || "Fetch Wallet Failed. Please try again" + ); + } + } else { + let _url = ``; + let _body = null; + switch (coin) { + case "BTC": + _url = await createEndpoint(`/crosschain/btc/walletbalance`); + + _body = parsedData.derivedMasterPublicKey; + break; + case "LTC": + _url = await createEndpoint(`/crosschain/ltc/walletbalance`); + _body = parsedData.ltcPublicKey; + break; + case "DOGE": + _url = await createEndpoint(`/crosschain/doge/walletbalance`); + _body = parsedData.dogePublicKey; + break; + case "DGB": + _url = await createEndpoint(`/crosschain/dgb/walletbalance`); + _body = parsedData.dgbPublicKey; + break; + case "RVN": + _url = await createEndpoint(`/crosschain/rvn/walletbalance`); + _body = parsedData.rvnPublicKey; + break; + case "ARRR": + _url = await createEndpoint(`/crosschain/arrr/walletbalance`); + _body = parsedData.arrrSeed58; + break; + default: + break; + } + try { + const response = await fetch(_url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: _body, + }); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + if (res?.error && res?.message) { + throw new Error(res.message); + } + if (isNaN(Number(res))) { + throw new Error("Unable to fetch balance"); + } else { + return (Number(res) / 1e8).toFixed(8); + } + } catch (error) { + throw new Error(error?.message || "Unable to fetch balance"); + } + } + } else { + throw new Error("User declined request"); + } +}; + +const getUserWalletFunc = async (coin) => { + let userWallet = {}; + const wallet = await getSaveWallet(); + const address = wallet.address0; + const resKeyPair = await getKeyPair(); + const parsedData = JSON.parse(resKeyPair); + switch (coin) { + case "QORT": + userWallet["address"] = address; + userWallet["publickey"] = parsedData.publicKey; + break; + case "BTC": + userWallet["address"] = parsedData.btcAddress; + userWallet["publickey"] = parsedData.btcPublicKey; + break; + case "LTC": + userWallet["address"] = parsedData.ltcAddress; + userWallet["publickey"] = parsedData.ltcPublicKey; + break; + case "DOGE": + userWallet["address"] = parsedData.dogeAddress; + userWallet["publickey"] = parsedData.dogePublicKey; + break; + case "DGB": + userWallet["address"] = parsedData.dgbAddress; + userWallet["publickey"] = parsedData.dgbPublicKey; + break; + case "RVN": + userWallet["address"] = parsedData.rvnAddress; + userWallet["publickey"] = parsedData.rvnPublicKey; + break; + case "ARRR": + break; + default: + break; + } + return userWallet; +}; + +export const getUserWalletInfo = async (data, isFromExtension) => { + const requiredFields = ["coin"]; + 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 resPermission = await getUserPermission({ + text1: "Do you give this application permission to retrieve your wallet information", + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + let coin = data.coin; + let walletKeys = await getUserWalletFunc(coin); + const _url = await createEndpoint( + `/crosschain/` + data.coin.toLowerCase() + `/addressinfos` + ); + let _body = { xpub58: walletKeys["publickey"] }; + try { + const response = await fetch(_url, { + method: "POST", + headers: { + Accept: "*/*", + "Content-Type": "application/json", + }, + body: JSON.stringify(_body), + }); + if(!response?.ok) throw new Error('Unable to fetch wallet information') + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + if (res?.error && res?.message) { + throw new Error(res.message); + } + + return res; + } catch (error) { + throw new Error(error?.message || "Fetch Wallet Failed"); + } + } else { + throw new Error("User declined request"); + } +}; + +export const getCrossChainServerInfo = async (data)=> { + const requiredFields = ['coin'] + 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 = `/crosschain/` + data.coin.toLowerCase() + `/serverinfos` + try { + + + const url = await createEndpoint(_url); + const response = await fetch(url); + if (!response.ok) throw new Error("Failed to fetch"); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + if (res?.error && res?.message) { + throw new Error(res.message); + } + return res.servers + } catch (error) { + + throw new Error(error?.message || 'Error in retrieving server info') + } +} + +export const getTxActivitySummary = async (data) => { + const requiredFields = ['coin']; + 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 coin = data.coin; + const url = `/crosschain/txactivity?foreignBlockchain=${coin}`; // No apiKey here + + try { + const endpoint = await createEndpoint(url); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) throw new Error('Failed to fetch'); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + if (res?.error && res?.message) { + throw new Error(res.message); + } + return res; // Return full response here + } catch (error) { + throw new Error(error?.message || 'Error in tx activity summary'); + } + }; + + export const getForeignFee = async (data) => { + const requiredFields = ['coin', '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); + } + + const { coin, type } = data; + const url = `/crosschain/${coin}/${type}`; + + try { + const endpoint = await createEndpoint(url); + const response = await fetch(endpoint, { + method: 'GET', + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) throw new Error('Failed to fetch'); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + if (res?.error && res?.message) { + throw new Error(res.message); + } + return res; // Return full response here + } catch (error) { + throw new Error(error?.message || 'Error in get foreign fee'); + } + }; + + export const updateForeignFee = async (data) => { + const localNodeAvailable = await isUsingLocal() + if(!localNodeAvailable) throw new Error('Please use your local node.') + const requiredFields = ['coin', 'type', 'value']; + 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 { coin, type, value } = data; + const url = `/crosschain/${coin}/update${type}`; + + try { + const endpoint = await createEndpoint(url); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ value }), + }); + + if (!response.ok) throw new Error('Failed to update foreign fee'); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + if (res?.error && res?.message) { + throw new Error(res.message); + } + return res; // Return full response here + } catch (error) { + throw new Error(error?.message || 'Error in update foreign fee'); + } + }; + + export const getServerConnectionHistory = async (data) => { + const requiredFields = ['coin']; + const missingFields: string[] = []; + + // Validate required fields + 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 coin = data.coin.toLowerCase(); + const url = `/crosschain/${coin}/serverconnectionhistory`; + + try { + const endpoint = await createEndpoint(url); // Assuming createEndpoint is available + const response = await fetch(endpoint, { + method: 'GET', + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) throw new Error('Failed to fetch server connection history'); + + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + + if (res?.error && res?.message) { + throw new Error(res.message); + } + + return res; // Return full response here + } catch (error) { + throw new Error(error?.message || 'Error in get server connection history'); + } + }; + + export const setCurrentForeignServer = async (data) => { + const localNodeAvailable = await isUsingLocal() + if(!localNodeAvailable) throw new Error('Please use your local node.') + const requiredFields = ['coin']; + const missingFields: string[] = []; + + // Validate required fields + 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 { coin, host, port, type } = data; + const body = { + hostName: host, + port: port, + connectionType: type, + }; + + const url = `/crosschain/${coin}/setcurrentserver`; + + try { + const endpoint = await createEndpoint(url); // Assuming createEndpoint is available + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) throw new Error('Failed to set current server'); + + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + + if (res?.error && res?.message) { + throw new Error(res.message); + } + + return res; // Return the full response + } catch (error) { + throw new Error(error?.message || 'Error in set current server'); + } + }; + + + export const addForeignServer = async (data) => { + const localNodeAvailable = await isUsingLocal() + if(!localNodeAvailable) throw new Error('Please use your local node.') + const requiredFields = ['coin']; + const missingFields: string[] = []; + + // Validate required fields + 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 { coin, host, port, type } = data; + const body = { + hostName: host, + port: port, + connectionType: type, + }; + + const url = `/crosschain/${coin}/addserver`; + + try { + const endpoint = await createEndpoint(url); // Assuming createEndpoint is available + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) throw new Error('Failed to add server'); + + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + + if (res?.error && res?.message) { + throw new Error(res.message); + } + + return res; // Return the full response + } catch (error) { + throw new Error(error.message || 'Error in adding server'); + } + }; + + export const removeForeignServer = async (data) => { + const localNodeAvailable = await isUsingLocal() + if(!localNodeAvailable) throw new Error('Please use your local node.') + const requiredFields = ['coin']; + const missingFields: string[] = []; + + // Validate required fields + 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 { coin, host, port, type } = data; + const body = { + hostName: host, + port: port, + connectionType: type, + }; + + const url = `/crosschain/${coin}/removeserver`; + + try { + const endpoint = await createEndpoint(url); // Assuming createEndpoint is available + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) throw new Error('Failed to remove server'); + + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + + if (res?.error && res?.message) { + throw new Error(res.message); + } + + return res; // Return the full response + } catch (error) { + throw new Error(error?.message || 'Error in removing server'); + } + }; + + export const getDaySummary = async () => { + const url = `/admin/summary`; // Simplified endpoint URL + + try { + const endpoint = await createEndpoint(url); // Assuming createEndpoint is available for constructing the full URL + const response = await fetch(endpoint, { + method: 'GET', + headers: { + Accept: '*/*', + }, + }); + + if (!response.ok) throw new Error('Failed to retrieve summary'); + + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + + if (res?.error && res?.message) { + throw new Error(res.message); + } + + return res; // Return the full response + } catch (error) { + throw new Error(error?.message || 'Error in retrieving summary'); + } + }; + +export const sendCoin = async (data, isFromExtension) => { + const requiredFields = ['coin', 'destinationAddress', 'amount'] + 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 checkCoin = data.coin + const wallet = await getSaveWallet(); + const address = wallet.address0; + const resKeyPair = await getKeyPair(); + const parsedData = JSON.parse(resKeyPair); + const localNodeAvailable = await isUsingLocal() + if(checkCoin !== 'QORT' && !localNodeAvailable) throw new Error('Cannot send a non-QORT coin through the gateway. Please use your local node.') + if (checkCoin === "QORT") { + // Params: data.coin, data.destinationAddress, data.amount, data.fee + // TODO: prompt user to send. If they confirm, call `POST /crosschain/:coin/send`, or for QORT, broadcast a PAYMENT transaction + // then set the response string from the core to the `response` variable (defined above) + // If they decline, send back JSON that includes an `error` key, such as `{"error": "User declined request"}` + const amount = Number(data.amount) + const recipient = data.destinationAddress + + const url = await createEndpoint(`/addresses/balance/${address}`); + const response = await fetch(url); + if (!response.ok) throw new Error("Failed to fetch"); + let walletBalance; + try { + walletBalance = await response.clone().json(); + } catch (e) { + walletBalance = await response.text(); + } + if (isNaN(Number(walletBalance))) { + let errorMsg = "Failed to Fetch QORT Balance. Try again!" + throw new Error(errorMsg) + } + + const transformDecimals = (Number(walletBalance) * QORT_DECIMALS).toFixed(0) + const walletBalanceDecimals = Number(transformDecimals) + const amountDecimals = Number(amount) * QORT_DECIMALS + const fee: number = await sendQortFee() + if (amountDecimals + (fee * QORT_DECIMALS) > walletBalanceDecimals) { + let errorMsg = "Insufficient Funds!" + throw new Error(errorMsg) + } + if (amount <= 0) { + let errorMsg = "Invalid Amount!" + throw new Error(errorMsg) + } + if (recipient.length === 0) { + let errorMsg = "Receiver cannot be empty!" + throw new Error(errorMsg) + } + + const resPermission = await getUserPermission({ + text1: "Do you give this application permission to send coins?", + text2: `To: ${recipient}`, + highlightedText: `${amount} ${checkCoin}`, + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const makePayment = await sendCoinFunc({amount, password: null, receiver: recipient }, true) + return makePayment.res + } else { + throw new Error("User declined request") + } + + } else if (checkCoin === "BTC") { + const amount = Number(data.amount) + const recipient = data.destinationAddress + const xprv58 = parsedData.btcPrivateKey + const feePerByte = data.fee ? data.fee : btcFeePerByte + + const btcWalletBalance = await getWalletBalance({coin: checkCoin}, true) + + if (isNaN(Number(btcWalletBalance))) { + throw new Error('Unable to fetch BTC balance') + } + const btcWalletBalanceDecimals = Number(btcWalletBalance) + const btcAmountDecimals = Number(amount) * QORT_DECIMALS + const fee = feePerByte * 500 // default 0.00050000 + if (btcAmountDecimals + (fee * QORT_DECIMALS) > btcWalletBalanceDecimals) { + throw new Error("INSUFFICIENT_FUNDS") + } + + const resPermission = await getUserPermission({ + text1: "Do you give this application permission to send coins?", + text2: `To: ${recipient}`, + highlightedText: `${amount} ${checkCoin}`, + fee: fee + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const opts = { + xprv58: xprv58, + receivingAddress: recipient, + bitcoinAmount: amount, + feePerByte: feePerByte * QORT_DECIMALS + } + const url = await createEndpoint(`/crosschain/btc/send`); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(opts) + }) + if (!response.ok) throw new Error("Failed to send"); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + return res; + + } else { + throw new Error("User declined request") + } + + } else if (checkCoin === "LTC") { + + const amount = Number(data.amount) + const recipient = data.destinationAddress + const xprv58 = parsedData.ltcPrivateKey + const feePerByte = data.fee ? data.fee : ltcFeePerByte + const ltcWalletBalance = await getWalletBalance({coin: checkCoin}, true) + + if (isNaN(Number(ltcWalletBalance))) { + let errorMsg = "Failed to Fetch LTC Balance. Try again!" + throw new Error(errorMsg) + } + const ltcWalletBalanceDecimals = Number(ltcWalletBalance) + const ltcAmountDecimals = Number(amount) * QORT_DECIMALS + const balance = (Number(ltcWalletBalance) / 1e8).toFixed(8) + const fee = feePerByte * 1000 // default 0.00030000 + if (ltcAmountDecimals + (fee * QORT_DECIMALS) > ltcWalletBalanceDecimals) { + throw new Error("Insufficient Funds!") + } + const resPermission = await getUserPermission({ + text1: "Do you give this application permission to send coins?", + text2: `To: ${recipient}`, + highlightedText: `${amount} ${checkCoin}`, + fee: fee + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const url = await createEndpoint(`/crosschain/ltc/send`); + const opts = { + xprv58: xprv58, + receivingAddress: recipient, + litecoinAmount: amount, + feePerByte: feePerByte * QORT_DECIMALS + } + const response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(opts) + }) + if (!response.ok) throw new Error("Failed to send"); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + return res; + } else { + throw new Error("User declined request") + } + + } else if (checkCoin === "DOGE") { + + const amount = Number(data.amount) + const recipient = data.destinationAddress + const coin = data.coin + const xprv58 = parsedData.dogePrivateKey + const feePerByte = data.fee ? data.fee : dogeFeePerByte + const dogeWalletBalance = await getWalletBalance({coin: checkCoin}, true) + if (isNaN(Number(dogeWalletBalance))) { + let errorMsg = "Failed to Fetch DOGE Balance. Try again!" + throw new Error(errorMsg) + } + const dogeWalletBalanceDecimals = Number(dogeWalletBalance) + const dogeAmountDecimals = Number(amount) * QORT_DECIMALS + const balance = (Number(dogeWalletBalance) / 1e8).toFixed(8) + const fee = feePerByte * 5000 // default 0.05000000 + if (dogeAmountDecimals + (fee * QORT_DECIMALS) > dogeWalletBalanceDecimals) { + let errorMsg = "Insufficient Funds!" + throw new Error(errorMsg) + } + + const resPermission = await getUserPermission({ + text1: "Do you give this application permission to send coins?", + text2: `To: ${recipient}`, + highlightedText: `${amount} ${checkCoin}`, + fee: fee + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const opts = { + xprv58: xprv58, + receivingAddress: recipient, + dogecoinAmount: amount, + feePerByte: feePerByte * QORT_DECIMALS + } + const url = await createEndpoint(`/crosschain/doge/send`); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(opts) + }) + if (!response.ok) throw new Error("Failed to send"); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + return res; + } else { + throw new Error("User declined request") + } + + } else if (checkCoin === "DGB") { + const amount = Number(data.amount) + const recipient = data.destinationAddress + const xprv58 = parsedData.dbgPrivateKey + const feePerByte = data.fee ? data.fee : dgbFeePerByte + const dgbWalletBalance = await getWalletBalance({coin: checkCoin}, true) + if (isNaN(Number(dgbWalletBalance))) { + let errorMsg = "Failed to Fetch DGB Balance. Try again!" + throw new Error(errorMsg) + } + const dgbWalletBalanceDecimals = Number(dgbWalletBalance) + const dgbAmountDecimals = Number(amount) * QORT_DECIMALS + const fee = feePerByte * 500 // default 0.00005000 + if (dgbAmountDecimals + (fee * QORT_DECIMALS) > dgbWalletBalanceDecimals) { + let errorMsg = "Insufficient Funds!" + throw new Error(errorMsg) + } + + const resPermission = await getUserPermission({ + text1: "Do you give this application permission to send coins?", + text2: `To: ${recipient}`, + highlightedText: `${amount} ${checkCoin}`, + fee: fee + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const opts = { + xprv58: xprv58, + receivingAddress: recipient, + digibyteAmount: amount, + feePerByte: feePerByte * QORT_DECIMALS + } + const url = await createEndpoint(`/crosschain/dgb/send`); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(opts) + }) + if (!response.ok) throw new Error("Failed to send"); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + return res; + } else { + throw new Error("User declined request") + } + + } else if (checkCoin === "RVN") { + const amount = Number(data.amount) + const recipient = data.destinationAddress + const coin = data.coin + const xprv58 = parsedData.rvnPrivateKey + const feePerByte = data.fee ? data.fee : rvnFeePerByte + const rvnWalletBalance = await getWalletBalance({coin: checkCoin}, true) + if (isNaN(Number(rvnWalletBalance))) { + let errorMsg = "Failed to Fetch RVN Balance. Try again!" + throw new Error(errorMsg) + } + const rvnWalletBalanceDecimals = Number(rvnWalletBalance) + const rvnAmountDecimals = Number(amount) * QORT_DECIMALS + const balance = (Number(rvnWalletBalance) / 1e8).toFixed(8) + const fee = feePerByte * 500 // default 0.00562500 + if (rvnAmountDecimals + (fee * QORT_DECIMALS) > rvnWalletBalanceDecimals) { + + let errorMsg = "Insufficient Funds!" + throw new Error(errorMsg) + } + + const resPermission = await getUserPermission({ + text1: "Do you give this application permission to send coins?", + text2: `To: ${recipient}`, + highlightedText: `${amount} ${checkCoin}`, + fee: fee + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const opts = { + xprv58: xprv58, + receivingAddress: recipient, + ravencoinAmount: amount, + feePerByte: feePerByte * QORT_DECIMALS + } + const url = await createEndpoint(`/crosschain/rvn/send`); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(opts) + }) + if (!response.ok) throw new Error("Failed to send"); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + return res; + } else { + throw new Error("User declined request") + } + } else if (checkCoin === "ARRR") { + const amount = Number(data.amount) + const recipient = data.destinationAddress + const memo = data.memo + const arrrWalletBalance = await getWalletBalance({coin: checkCoin}, true) + + if (isNaN(Number(arrrWalletBalance))) { + let errorMsg = "Failed to Fetch ARRR Balance. Try again!" + throw new Error(errorMsg) + } + const arrrWalletBalanceDecimals = Number(arrrWalletBalance) + const arrrAmountDecimals = Number(amount) * QORT_DECIMALS + const fee = 0.00010000 + if (arrrAmountDecimals + (fee * QORT_DECIMALS) > arrrWalletBalanceDecimals) { + let errorMsg = "Insufficient Funds!" + throw new Error(errorMsg) + } + + const resPermission = await getUserPermission({ + text1: "Do you give this application permission to send coins?", + text2: `To: ${recipient}`, + highlightedText: `${amount} ${checkCoin}`, + fee: fee + }, isFromExtension); + const { accepted } = resPermission; + + if (accepted) { + const opts = { + entropy58: parsedData.arrrSeed58, + receivingAddress: recipient, + arrrAmount: amount, + memo: memo + } + const url = await createEndpoint(`/crosschain/btc/send`); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(opts) + }) + if (!response.ok) throw new Error("Failed to send"); + let res; + try { + res = await response.clone().json(); + } catch (e) { + res = await response.text(); + } + return res; + } else { + throw new Error("User declined request") + } + } +}; diff --git a/src/transactions/CreatePollTransaction.ts b/src/transactions/CreatePollTransaction.ts new file mode 100644 index 0000000..a2a9cc0 --- /dev/null +++ b/src/transactions/CreatePollTransaction.ts @@ -0,0 +1,73 @@ +// @ts-nocheck +import { QORT_DECIMALS } from '../constants/constants' +import TransactionBase from './TransactionBase' + +export default class CreatePollTransaction extends TransactionBase { + constructor() { + super() + this.type = 8 + this._options = [] + } + + addOption(option) { + const optionBytes = this.constructor.utils.stringtoUTF8Array(option) + const optionLength = this.constructor.utils.int32ToBytes(optionBytes.length) + this._options.push({ length: optionLength, bytes: optionBytes }) + } + + + set fee(fee) { + this._fee = fee * QORT_DECIMALS + this._feeBytes = this.constructor.utils.int64ToBytes(this._fee) + } + + set ownerAddress(ownerAddress) { + this._ownerAddress = ownerAddress instanceof Uint8Array ? ownerAddress : this.constructor.Base58.decode(ownerAddress) + } + + set rPollName(rPollName) { + this._rPollName = rPollName + this._rPollNameBytes = this.constructor.utils.stringtoUTF8Array(this._rPollName) + this._rPollNameLength = this.constructor.utils.int32ToBytes(this._rPollNameBytes.length) + + } + + set rPollDesc(rPollDesc) { + this._rPollDesc = rPollDesc + this._rPollDescBytes = this.constructor.utils.stringtoUTF8Array(this._rPollDesc) + this._rPollDescLength = this.constructor.utils.int32ToBytes(this._rPollDescBytes.length) + } + + set rOptions(rOptions) { + const optionsArray = rOptions[0].split(', ').map(opt => opt.trim()) + this._pollOptions = optionsArray + + for (let i = 0; i < optionsArray.length; i++) { + this.addOption(optionsArray[i]) + } + + this._rNumberOfOptionsBytes = this.constructor.utils.int32ToBytes(optionsArray.length) + } + + + get params() { + const params = super.params + params.push( + this._ownerAddress, + this._rPollNameLength, + this._rPollNameBytes, + this._rPollDescLength, + this._rPollDescBytes, + this._rNumberOfOptionsBytes + ) + + // Push the dynamic options + for (let i = 0; i < this._options.length; i++) { + params.push(this._options[i].length, this._options[i].bytes) + } + + params.push(this._feeBytes) + + return params + } +} diff --git a/src/transactions/DeployAtTransaction.ts b/src/transactions/DeployAtTransaction.ts new file mode 100644 index 0000000..8a20553 --- /dev/null +++ b/src/transactions/DeployAtTransaction.ts @@ -0,0 +1,78 @@ +// @ts-nocheck + + +import TransactionBase from './TransactionBase' +import { QORT_DECIMALS } from '../constants/constants' + +export default class DeployAtTransaction extends TransactionBase { + constructor() { + super() + this.type = 16 + } + + + + set fee(fee) { + this._fee = fee * QORT_DECIMALS + this._feeBytes = this.constructor.utils.int64ToBytes(this._fee) + } + + set rAmount(rAmount) { + this._rAmount = Math.round(rAmount * QORT_DECIMALS) + this._rAmountBytes = this.constructor.utils.int64ToBytes(this._rAmount) + } + + set rName(rName) { + this._rName = rName + this._rNameBytes = this.constructor.utils.stringtoUTF8Array(this._rName.toLocaleLowerCase()) + this._rNameLength = this.constructor.utils.int32ToBytes(this._rNameBytes.length) + } + + set rDescription(rDescription) { + this._rDescription = rDescription + this._rDescriptionBytes = this.constructor.utils.stringtoUTF8Array(this._rDescription.toLocaleLowerCase()) + this._rDescriptionLength = this.constructor.utils.int32ToBytes(this._rDescriptionBytes.length) + } + + set atType(atType) { + this._atType = atType + this._atTypeBytes = this.constructor.utils.stringtoUTF8Array(this._atType) + this._atTypeLength = this.constructor.utils.int32ToBytes(this._atTypeBytes.length) + } + + set rTags(rTags) { + this._rTags = rTags + this._rTagsBytes = this.constructor.utils.stringtoUTF8Array(this._rTags.toLocaleLowerCase()) + this._rTagsLength = this.constructor.utils.int32ToBytes(this._rTagsBytes.length) + } + + set rCreationBytes(rCreationBytes) { + const decode = this.constructor.Base58.decode(rCreationBytes) + this._rCreationBytes = this.constructor.utils.stringtoUTF8Array(decode) + this._rCreationBytesLength = this.constructor.utils.int32ToBytes(this._rCreationBytes.length) + } + + set rAssetId(rAssetId) { + this._rAssetId = this.constructor.utils.int64ToBytes(rAssetId) + } + + get params() { + const params = super.params + params.push( + this._rNameLength, + this._rNameBytes, + this._rDescriptionLength, + this._rDescriptionBytes, + this._atTypeLength, + this._atTypeBytes, + this._rTagsLength, + this._rTagsBytes, + this._rCreationBytesLength, + this._rCreationBytes, + this._rAmountBytes, + this._rAssetId, + this._feeBytes + ) + return params + } +} diff --git a/src/transactions/VoteOnPollTransaction.ts b/src/transactions/VoteOnPollTransaction.ts new file mode 100644 index 0000000..327295e --- /dev/null +++ b/src/transactions/VoteOnPollTransaction.ts @@ -0,0 +1,38 @@ +// @ts-nocheck +import { QORT_DECIMALS } from '../constants/constants' +import TransactionBase from './TransactionBase' + +export default class VoteOnPollTransaction extends TransactionBase { + constructor() { + super() + this.type = 9 + } + + + set fee(fee) { + this._fee = fee * QORT_DECIMALS + this._feeBytes = this.constructor.utils.int64ToBytes(this._fee) + } + + set rPollName(rPollName) { + this._rPollName = rPollName + this._rPollNameBytes = this.constructor.utils.stringtoUTF8Array(this._rPollName) + this._rPollNameLength = this.constructor.utils.int32ToBytes(this._rPollNameBytes.length) + } + + set rOptionIndex(rOptionIndex) { + this._rOptionIndex = rOptionIndex + this._rOptionIndexBytes = this.constructor.utils.int32ToBytes(this._rOptionIndex) + } + + get params() { + const params = super.params + params.push( + this._rPollNameLength, + this._rPollNameBytes, + this._rOptionIndexBytes, + this._feeBytes + ) + return params + } +} diff --git a/src/transactions/transactions.ts b/src/transactions/transactions.ts index 25ddbf5..400418c 100644 --- a/src/transactions/transactions.ts +++ b/src/transactions/transactions.ts @@ -14,11 +14,17 @@ import JoinGroupTransaction from './JoinGroupTransaction.js' import AddGroupAdminTransaction from './AddGroupAdminTransaction.js' import RemoveGroupAdminTransaction from './RemoveGroupAdminTransaction.js' import RegisterNameTransaction from './RegisterNameTransaction.js' +import VoteOnPollTransaction from './VoteOnPollTransaction.js' +import CreatePollTransaction from './CreatePollTransaction.js' +import DeployAtTransaction from './DeployAtTransaction.js' export const transactionTypes = { 3: RegisterNameTransaction, 2: PaymentTransaction, + 8: CreatePollTransaction, + 9: VoteOnPollTransaction, + 16: DeployAtTransaction, 18: ChatTransaction, 181: GroupChatTransaction, 22: CreateGroupTransaction, diff --git a/src/useAppFullscreen.tsx b/src/useAppFullscreen.tsx new file mode 100644 index 0000000..3dbe4a1 --- /dev/null +++ b/src/useAppFullscreen.tsx @@ -0,0 +1,67 @@ +import { useCallback, useEffect } from 'react'; +import { isMobile } from './App'; + +export const useAppFullScreen = (setFullScreen) => { + const enterFullScreen = useCallback(() => { + const element = document.documentElement; // Target the entire HTML document + if (element.requestFullscreen) { + element.requestFullscreen(); + } else if (element.mozRequestFullScreen) { // Firefox + element.mozRequestFullScreen(); + } else if (element.webkitRequestFullscreen) { // Chrome, Safari and Opera + element.webkitRequestFullscreen(); + } else if (element.msRequestFullscreen) { // IE/Edge + element.msRequestFullscreen(); + } + }, []); + + const exitFullScreen = useCallback(() => { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else if (document.mozFullScreenElement) { + document.mozCancelFullScreen(); + } else if (document.webkitFullscreenElement) { + document.webkitExitFullscreen(); + } else if (document.msFullscreenElement) { + document.msExitFullscreen(); + } + }, []); + + const toggleFullScreen = useCallback(() => { + if(!isMobile || isMobile) return + if (document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement) { + exitFullScreen(); + setFullScreen(false) + } else { + enterFullScreen(); + setFullScreen(true) + } + }, [enterFullScreen, exitFullScreen]); + + // Listen for changes to fullscreen state + useEffect(() => { + const handleFullScreenChange = () => { + if (document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement) { + + } else { + setFullScreen(false); + } + }; + + document.addEventListener('fullscreenchange', handleFullScreenChange); + document.addEventListener('webkitfullscreenchange', handleFullScreenChange); // Safari + document.addEventListener('mozfullscreenchange', handleFullScreenChange); // Firefox + document.addEventListener('MSFullscreenChange', handleFullScreenChange); // IE/Edge + + return () => { + document.removeEventListener('fullscreenchange', handleFullScreenChange); + document.removeEventListener('webkitfullscreenchange', handleFullScreenChange); + document.removeEventListener('mozfullscreenchange', handleFullScreenChange); + document.removeEventListener('MSFullscreenChange', handleFullScreenChange); + }; + }, []); + + return { enterFullScreen, exitFullScreen, toggleFullScreen }; +}; + + diff --git a/src/useQortalGetSaveSettings.tsx b/src/useQortalGetSaveSettings.tsx new file mode 100644 index 0000000..0223f12 --- /dev/null +++ b/src/useQortalGetSaveSettings.tsx @@ -0,0 +1,94 @@ +import React, { useCallback, useEffect } from 'react' +import { useRecoilState, useSetRecoilState } from 'recoil'; +import { canSaveSettingToQdnAtom, oldPinnedAppsAtom, settingsLocalLastUpdatedAtom, settingsQDNLastUpdatedAtom, sortablePinnedAppsAtom } from './atoms/global'; +import { getArbitraryEndpointReact, getBaseApiReact } from './App'; +import { decryptResource } from './components/Group/Group'; +import { base64ToUint8Array, uint8ArrayToObject } from './backgroundFunctions/encryption'; + +function fetchFromLocalStorage(key) { + try { + const serializedValue = localStorage.getItem(key); + if (serializedValue === null) { + console.log(`No data found for key: ${key}`); + return null; + } + return JSON.parse(serializedValue); + } catch (error) { + console.error('Error fetching from localStorage:', error); + return null; + } +} + +const getPublishRecord = async (myName) => { + // const validApi = await findUsableApi(); + const url = `${getBaseApiReact()}${getArbitraryEndpointReact()}?mode=ALL&service=DOCUMENT_PRIVATE&identifier=ext_saved_settings&exactmatchnames=true&limit=1&prefix=true&name=${myName}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error("network error"); + } + const publishData = await response.json(); + + if(publishData?.length > 0) return {hasPublishRecord: true, timestamp: publishData[0]?.updated || publishData[0].created} + + return {hasPublishRecord: false} + }; + const getPublish = async (myName) => { + try { + let data + const res = await fetch( + `${getBaseApiReact()}/arbitrary/DOCUMENT_PRIVATE/${myName}/ext_saved_settings?encoding=base64` + ); + data = await res.text(); + + + if(!data) throw new Error('Unable to fetch publish') + + const decryptedKey: any = await decryptResource(data); + + const dataint8Array = base64ToUint8Array(decryptedKey.data); + const decryptedKeyToObject = uint8ArrayToObject(dataint8Array); + return decryptedKeyToObject + } catch (error) { + return null + } + }; + +export const useQortalGetSaveSettings = (myName) => { + const setSortablePinnedApps = useSetRecoilState(sortablePinnedAppsAtom); + const setCanSave = useSetRecoilState(canSaveSettingToQdnAtom); + const setSettingsQDNLastUpdated = useSetRecoilState(settingsQDNLastUpdatedAtom); + const [settingsLocalLastUpdated] = useRecoilState(settingsLocalLastUpdatedAtom); + const [oldPinnedApps, setOldPinnedApps] = useRecoilState(oldPinnedAppsAtom) + + const getSavedSettings = useCallback(async (myName, settingsLocalLastUpdated)=> { + try { + const {hasPublishRecord, timestamp} = await getPublishRecord(myName) + if(hasPublishRecord){ + const settings = await getPublish(myName) + if(settings?.sortablePinnedApps && timestamp > settingsLocalLastUpdated){ + setSortablePinnedApps(settings.sortablePinnedApps) + + setSettingsQDNLastUpdated(timestamp || 0) + } else if(settings?.sortablePinnedApps){ + setSettingsQDNLastUpdated(timestamp || 0) + setOldPinnedApps(settings.sortablePinnedApps) + } + if(!settings){ + // set -100 to indicate that it couldn't fetch the publish + setSettingsQDNLastUpdated(-100) + + } + } else { + setSettingsQDNLastUpdated( 0) + } + setCanSave(true) + } catch (error) { + + } + }, []) + useEffect(()=> { + if(!myName || !settingsLocalLastUpdated) return + getSavedSettings(myName, settingsLocalLastUpdated) + }, [getSavedSettings, myName, settingsLocalLastUpdated]) + +} diff --git a/src/useRetrieveDataLocalStorage.tsx b/src/useRetrieveDataLocalStorage.tsx new file mode 100644 index 0000000..6d7b05e --- /dev/null +++ b/src/useRetrieveDataLocalStorage.tsx @@ -0,0 +1,35 @@ +import React, { useCallback, useEffect } from 'react' +import { useSetRecoilState } from 'recoil'; +import { settingsLocalLastUpdatedAtom, sortablePinnedAppsAtom } from './atoms/global'; + +function fetchFromLocalStorage(key) { + try { + const serializedValue = localStorage.getItem(key); + if (serializedValue === null) { + console.log(`No data found for key: ${key}`); + return null; + } + return JSON.parse(serializedValue); + } catch (error) { + console.error('Error fetching from localStorage:', error); + return null; + } +} + +export const useRetrieveDataLocalStorage = () => { + const setSortablePinnedApps = useSetRecoilState(sortablePinnedAppsAtom); + const setSettingsLocalLastUpdated = useSetRecoilState(settingsLocalLastUpdatedAtom); + + const getSortablePinnedApps = useCallback(()=> { + const pinnedAppsLocal = fetchFromLocalStorage('ext_saved_settings') + if(pinnedAppsLocal?.sortablePinnedApps){ + setSortablePinnedApps(pinnedAppsLocal?.sortablePinnedApps) + } + setSettingsLocalLastUpdated(pinnedAppsLocal?.timestamp || -1) + }, []) + useEffect(()=> { + + getSortablePinnedApps() + }, [getSortablePinnedApps]) + +} diff --git a/src/utils/memeTypes.ts b/src/utils/memeTypes.ts new file mode 100644 index 0000000..2bc5873 --- /dev/null +++ b/src/utils/memeTypes.ts @@ -0,0 +1,56 @@ +export const mimeToExtensionMap = { + // Documents + "application/pdf": ".pdf", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.ms-excel": ".xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "application/vnd.oasis.opendocument.text": ".odt", + "application/vnd.oasis.opendocument.spreadsheet": ".ods", + "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", + + // Images + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + "image/tiff": ".tif", + "image/bmp": ".bmp", + + // Audio + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/wav": ".wav", + "audio/webm": ".weba", + "audio/aac": ".aac", + + // Video + "video/mp4": ".mp4", + "video/webm": ".webm", + "video/ogg": ".ogv", + "video/x-msvideo": ".avi", + "video/quicktime": ".mov", + "video/x-ms-wmv": ".wmv", + "video/mpeg": ".mpeg", + "video/3gpp": ".3gp", + "video/3gpp2": ".3g2", + "video/x-matroska": ".mkv", + "video/x-flv": ".flv", + + // Archives + "application/zip": ".zip", + "application/x-rar-compressed": ".rar", + "application/x-tar": ".tar", + "application/x-7z-compressed": ".7z", + "application/x-gzip": ".gz", + "application/x-bzip2": ".bz2", +} \ No newline at end of file