version 2 - beta

This commit is contained in:
PhilReact 2024-09-09 20:36:39 +03:00
parent c2fcfaeaed
commit f75efc8cf6
99 changed files with 20541 additions and 757 deletions

View File

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qortal Extension</title> <title>Qortal Extension</title>
</head> </head>

3026
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,43 +5,73 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"coverage": "vitest run --coverage" "coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@chatscope/chat-ui-kit-react": "^2.0.3",
"@emotion/react": "^11.11.4", "@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.16.4", "@mui/icons-material": "^5.16.4",
"@mui/material": "^5.15.14", "@mui/lab": "^5.0.0-alpha.173",
"@mui/material": "^5.16.7",
"@reduxjs/toolkit": "^2.2.7",
"@testing-library/jest-dom": "^6.4.6", "@testing-library/jest-dom": "^6.4.6",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@tiptap/extension-color": "^2.5.9",
"@tiptap/extension-image": "^2.6.6",
"@tiptap/extension-placeholder": "^2.6.2",
"@tiptap/extension-text-style": "^2.5.9",
"@tiptap/pm": "^2.5.9",
"@tiptap/react": "^2.5.9",
"@tiptap/starter-kit": "^2.5.9",
"@types/chrome": "^0.0.263", "@types/chrome": "^0.0.263",
"asmcrypto.js": "2.3.2", "asmcrypto.js": "2.3.2",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"buffer": "6.0.3", "buffer": "6.0.3",
"compressorjs": "^1.2.1",
"dompurify": "^3.1.6",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jssha": "3.3.1", "jssha": "3.3.1",
"lodash": "^4.17.21",
"mime": "^4.0.4",
"moment": "^2.30.1",
"quill-image-resize-module-react": "^3.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0", "react-copy-to-clipboard": "^5.1.0",
"react-countdown-circle-timer": "^3.2.1", "react-countdown-circle-timer": "^3.2.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3" "react-dropzone": "^14.2.3",
"react-infinite-scroller": "^1.2.6",
"react-intersection-observer": "^9.13.0",
"react-quill": "^2.0.0",
"react-redux": "^9.1.2",
"react-virtualized": "^9.22.5",
"short-unique-id": "^5.2.0",
"slate": "^0.103.0",
"slate-react": "^0.109.0",
"tiptap-extension-resize-image": "^1.1.8"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^10.3.0", "@testing-library/dom": "^10.3.0",
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.7",
"@types/react": "^18.2.64", "@types/react": "^18.2.64",
"@types/react-copy-to-clipboard": "^5.0.7", "@types/react-copy-to-clipboard": "^5.0.7",
"@types/react-dom": "^18.2.21", "@types/react-dom": "^18.2.21",
"@types/react-infinite-scroller": "^1.2.5",
"@types/react-virtualized": "^9.21.30",
"@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1", "@typescript-eslint/parser": "^7.1.1",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.5",
"rename-cli": "^6.2.1",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.1.6", "vite": "^5.1.6",
"vitest": "^1.6.0" "vitest": "^1.6.0"

View File

@ -1,3 +1,4 @@
async function connection(hostname) { async function connection(hostname) {
const isConnected = await chrome.storage.local.get([hostname]); const isConnected = await chrome.storage.local.get([hostname]);
let connected = false let connected = false
@ -48,6 +49,7 @@ document.addEventListener('qortalExtensionRequests', async (event) => {
} }
}); });
} else if (type === 'REQUEST_CONNECTION') { } else if (type === 'REQUEST_CONNECTION') {
console.log('REQUEST_CONNECTION')
const hostname = window.location.hostname const hostname = window.location.hostname
chrome.runtime.sendMessage({ action: "connection", payload: { chrome.runtime.sendMessage({ action: "connection", payload: {
hostname hostname
@ -122,7 +124,7 @@ document.addEventListener('qortalExtensionRequests', async (event) => {
} }
}); });
} else if(type === 'REQUEST_LTC_BALANCE'){ } else if(type === 'REQUEST_LTC_BALANCE'){
const hostname = window.location.hostname const hostname = window.location.hostname
const res = await connection(hostname) const res = await connection(hostname)
@ -137,6 +139,7 @@ document.addEventListener('qortalExtensionRequests', async (event) => {
chrome.runtime.sendMessage({ action: "ltcBalance", payload: { chrome.runtime.sendMessage({ action: "ltcBalance", payload: {
hostname hostname
}, timeout }, (response) => { }, timeout }, (response) => {
if (response.error) { if (response.error) {
document.dispatchEvent(new CustomEvent('qortalExtensionResponses', { document.dispatchEvent(new CustomEvent('qortalExtensionResponses', {
detail: { type: "LTC_BALANCE", data: { detail: { type: "LTC_BALANCE", data: {

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -14,7 +14,7 @@
}, },
"action": { "action": {
}, },
"permissions": [ "storage", "system.display", "activeTab", "tabs" "permissions": [ "storage", "system.display", "activeTab", "tabs", "notifications", "alarms"
], ],
"content_scripts": [ "content_scripts": [
@ -24,6 +24,6 @@
} }
], ],
"content_security_policy": { "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;" "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; "
} }
} }

BIN
public/msg-not1.wav Normal file

Binary file not shown.

View File

@ -40,6 +40,15 @@ export const AuthenticatedContainerInnerRight = styled(Box)(({ theme }) => ({
background: "rgba(0, 0, 0, 0.1)" background: "rgba(0, 0, 0, 0.1)"
})); }));
export const AuthenticatedContainerInnerTop = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
width: "100%px",
height: "60px",
background: "rgba(0, 0, 0, 0.1)",
padding: '20px'
}));
export const TextP = styled(Typography)(({ theme }) => ({ export const TextP = styled(Typography)(({ theme }) => ({
fontSize: "13px", fontSize: "13px",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
<svg width="11" height="7" viewBox="0 0 11 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.57143 0L0 1.55556L5.5 7L11 1.55556L9.42857 0L5.5 3.88889L1.57143 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 197 B

View File

@ -0,0 +1,3 @@
<svg width="11" height="19" viewBox="0 0 11 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.04183 -2.20378e-07C7.8285 -9.85692e-08 10.0835 2.255 10.0835 5.04167L10.0835 14.6667C10.0835 16.6925 8.44266 18.3333 6.41683 18.3333C4.391 18.3333 2.75016 16.6925 2.75016 14.6667L2.75016 6.875C2.75016 5.61 3.77683 4.58333 5.04183 4.58333C6.30683 4.58333 7.3335 5.61 7.3335 6.875L7.3335 13.75L5.50016 13.75L5.50016 6.7925C5.50016 6.28833 4.5835 6.28833 4.5835 6.7925L4.5835 14.6667C4.5835 15.675 5.4085 16.5 6.41683 16.5C7.42516 16.5 8.25016 15.675 8.25016 14.6667L8.25016 5.04167C8.25016 3.2725 6.811 1.83333 5.04183 1.83333C3.27266 1.83333 1.8335 3.2725 1.8335 5.04167L1.8335 13.75L0.000162477 13.75L0.000162858 5.04167C0.00016298 2.255 2.25516 -3.42187e-07 5.04183 -2.20378e-07Z" fill="#A6A0A0"/>
</svg>

After

Width:  |  Height:  |  Size: 814 B

View File

@ -0,0 +1,3 @@
<svg width="22" height="17" viewBox="0 0 22 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.1745 3.10899L9.44157 14.8419L7.93313 16.3504L6.42468 14.8419L0.0249023 8.44214L3.04179 5.42525L7.93313 10.3166L18.1576 0.0921021L21.1745 3.10899Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@ -0,0 +1,9 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="20" height="20" fill="url(#pattern0)"/>
<defs>
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_127_477" transform="scale(0.015625)"/>
</pattern>
<image id="image0_127_477" width="64" height="64" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsTAAALEwEAmpwYAAACW0lEQVR4nO3bu2sUURSA8RsVMQRRfBArQ3yAb8RC0MZKsLBRtLHQQvEfEBEVS+2sjQHjo1TERhCLNHZWVsFGxShiFLTygSj+5OIKm2E3m12Tnbk789V7z+53zpl7z87uhFBRUZEiWIi9uIQx3MUozmMX+kIvgkU4iUkz8xxHeyoRWIlx7fEQy0LqYBivdMYzLA2Jy082EPuCERzDfpzCffxs8NoHSV4Omss/wmCTNTsw0WDNkdAj8jewoMXa5Q2SMJFMF2guP9ZKPtMJvzLrd4aE5W/OVr4uVtwT6jkXEpW/1a58LV7cGOsZCQnK34mTX4cxD2Ri3QuJyU9h4D/ixiOyntGQkHz9kbekw9jXTediSEy+4yRgDb6azu6QoHzbSYgbJh6bzstONtJ5AUMdzvZRqr9F7D5ca7D2eEi08rPuhFrl47CUZbzTk6Ro8k2TUJOPw1KW2GmrQsJt3/JyqMnHYSnLG6zvRfn6JAwUXX54jtq+GXFYyhLfb10vV34mClP5oUq+u7wtu/yGvN1DJa+qfDcpddu/x+a83UMlr6p8Nyl120+VXX5L3u6hkldVvptUbR/yRrXhyWO331qEyq/G6xyGnE2hCOBsDvL5Dzn/qP23rlt8KETbZ35X+1RK+UgcObsovy0UDZwurXwEt0srH8GLMssPllY+gsPzJP+x8PIRXJ0n+e0hBfC0zPL9+FFK+Qj2lVY+ggullZ+DL0Df8QSXsTakiL/ndDs3LuIzOGewB4tD6uBdE9nftUdN4kOJJ7Ax9CI4hM/4VmvnKziIFXl/toqKMG/8Ad4tdVYcSnGDAAAAAElFTkSuQmCC"/>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,9 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="20" height="20" fill="url(#pattern0)"/>
<defs>
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_127_477" transform="scale(0.015625)"/>
</pattern>
<image id="image0_127_477" width="64" height="64" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsTAAALEwEAmpwYAAACW0lEQVR4nO3bu2sUURSA8RsVMQRRfBArQ3yAb8RC0MZKsLBRtLHQQvEfEBEVS+2sjQHjo1TERhCLNHZWVsFGxShiFLTygSj+5OIKm2E3m12Tnbk789V7z+53zpl7z87uhFBRUZEiWIi9uIQx3MUozmMX+kIvgkU4iUkz8xxHeyoRWIlx7fEQy0LqYBivdMYzLA2Jy082EPuCERzDfpzCffxs8NoHSV4Omss/wmCTNTsw0WDNkdAj8jewoMXa5Q2SMJFMF2guP9ZKPtMJvzLrd4aE5W/OVr4uVtwT6jkXEpW/1a58LV7cGOsZCQnK34mTX4cxD2Ri3QuJyU9h4D/ixiOyntGQkHz9kbekw9jXTediSEy+4yRgDb6azu6QoHzbSYgbJh6bzstONtJ5AUMdzvZRqr9F7D5ca7D2eEi08rPuhFrl47CUZbzTk6Ro8k2TUJOPw1KW2GmrQsJt3/JyqMnHYSnLG6zvRfn6JAwUXX54jtq+GXFYyhLfb10vV34mClP5oUq+u7wtu/yGvN1DJa+qfDcpddu/x+a83UMlr6p8Nyl120+VXX5L3u6hkldVvptUbR/yRrXhyWO331qEyq/G6xyGnE2hCOBsDvL5Dzn/qP23rlt8KETbZ35X+1RK+UgcObsovy0UDZwurXwEt0srH8GLMssPllY+gsPzJP+x8PIRXJ0n+e0hBfC0zPL9+FFK+Qj2lVY+ggullZ+DL0Df8QSXsTakiL/ndDs3LuIzOGewB4tD6uBdE9nftUdN4kOJJ7Ax9CI4hM/4VmvnKziIFXl/toqKMG/8Ad4tdVYcSnGDAAAAAElFTkSuQmCC"/>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,20 @@
import React from 'react';
import { styled } from '@mui/system';
import { SVGProps } from './interfaces';
// Create a styled container with hover effects
const SvgContainer = styled('svg')({
'& path': {
fill: 'rgba(41, 41, 43, 1)', // Default to red if no color prop
}
});
export const CreateThreadIcon:React.FC<SVGProps> = ({ color, opacity }) => {
return (
<SvgContainer width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 9.80209V9.0205C0.0460138 8.67679 0.080024 8.31425 0.144043 7.98466C0.469856 6.30568 1.25577 4.79934 2.38071 3.6977C4.13924 1.88262 6.22987 0.985679 8.52256 0.674927C9.9086 0.485649 11.3116 0.565177 12.6758 0.910345C14.5124 1.34351 16.1889 2.2075 17.6053 3.67886C18.7276 4.84183 19.5319 6.24257 19.858 7.98466C19.918 8.31189 19.952 8.64383 20 8.97577V9.80209C19.9827 9.8676 19.9693 9.93447 19.96 10.0022C19.8708 11.2186 19.5113 12.3861 18.9177 13.3875C17.961 15.0025 16.6297 16.2594 15.0825 17.0082C12.4657 18.3525 9.75693 18.5667 6.98209 17.8346C6.8589 17.8074 6.73157 17.8264 6.61799 17.8887C5.15955 18.7339 3.70511 19.5908 2.24867 20.4501C2.18866 20.4854 2.12464 20.5183 2.0146 20.5748L3.78714 16.3703C3.37301 16.0148 2.96889 15.7017 2.60078 15.3415C1.42243 14.1879 0.556167 12.7895 0.182055 11.0192C0.0980294 10.6213 0.060018 10.2094 0 9.80209ZM14.0042 10.5931C14.1362 10.5968 14.2676 10.5698 14.3907 10.5135C14.5138 10.4572 14.6262 10.3728 14.7214 10.2651C14.8167 10.1574 14.8928 10.0286 14.9455 9.8861C14.9982 9.7436 15.0264 9.59023 15.0285 9.43484V9.4113C15.0285 9.25517 15.0024 9.10058 14.9516 8.95634C14.9008 8.8121 14.8264 8.68104 14.7326 8.57064C14.6388 8.46025 14.5274 8.37268 14.4048 8.31293C14.2823 8.25319 14.1509 8.22243 14.0182 8.22243C13.8855 8.22243 13.7542 8.25319 13.6316 8.31293C13.509 8.37268 13.3976 8.46025 13.3038 8.57064C13.21 8.68104 13.1356 8.8121 13.0848 8.95634C13.034 9.10058 13.0079 9.25517 13.0079 9.4113C13.0074 9.56588 13.0327 9.71906 13.0825 9.86211C13.1323 10.0052 13.2055 10.1353 13.2981 10.245C13.3906 10.3547 13.5005 10.442 13.6217 10.5017C13.7429 10.5614 13.8728 10.5925 14.0042 10.5931ZM10.003 10.5931C10.203 10.5926 10.3983 10.5225 10.5644 10.3915C10.7306 10.2606 10.86 10.0746 10.9364 9.85719C11.0129 9.63976 11.0329 9.40056 10.9939 9.16977C10.9549 8.93898 10.8588 8.72694 10.7175 8.5604C10.5763 8.39385 10.3962 8.28026 10.2002 8.23396C10.0041 8.18765 9.80084 8.21071 9.61591 8.30022C9.43099 8.38973 9.27273 8.54168 9.1611 8.7369C9.04948 8.93212 8.98949 9.16187 8.9887 9.39717C8.98975 9.71356 9.09688 10.0167 9.28682 10.2406C9.47675 10.4646 9.73413 10.5912 10.003 10.5931ZM4.98349 9.3854C4.9836 9.61979 5.04316 9.8488 5.15456 10.0431C5.26595 10.2374 5.42411 10.3882 5.60876 10.476C5.79341 10.5639 5.99616 10.5849 6.19102 10.5364C6.38588 10.4878 6.56399 10.3719 6.70252 10.2035C6.84105 10.0351 6.93371 9.82183 6.96861 9.59108C7.00352 9.36032 6.97909 9.12255 6.89845 8.90823C6.8178 8.69392 6.68463 8.51281 6.51597 8.38811C6.34732 8.26342 6.15087 8.20081 5.95179 8.20831C5.69208 8.21809 5.44579 8.34641 5.26507 8.56611C5.08434 8.78581 4.98336 9.07963 4.98349 9.3854Z" fill="#29292B"/>
</SvgContainer>
);
};

View File

@ -0,0 +1,3 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.14468 0L0.394043 2.66667L5.89531 8L0.394043 13.3333L3.14468 16L8.64594 10.6667L14.1472 16L16.8978 13.3333L11.3966 8L16.8978 2.66667L14.1472 0L8.64594 5.33333L3.14468 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 300 B

3
src/assets/svgs/More.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.87531 8.48462C5.49219 9.1523 4.52994 9.15487 4.14327 8.48923L1.54475 4.01604C1.15808 3.3504 1.63698 2.51579 2.40678 2.51374L7.57993 2.49995C8.34973 2.4979 8.83308 3.32995 8.44995 3.99764L5.87531 8.48462Z" fill="#D9D9D9"/>
</svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@ -0,0 +1,19 @@
import React from 'react';
import { styled } from '@mui/system';
import { SVGProps } from './interfaces';
// Create a styled container with hover effects
const SvgContainer = styled('svg')({
'& path': {
fill: 'rgba(41, 41, 43, 1)', // Default to red if no color prop
}
});
export const SendNewMessage:React.FC<SVGProps> = ({ color, opacity }) => {
return (
<SvgContainer width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.33271 10.2306C2.88006 10.001 2.89088 9.65814 3.3554 9.46527L16.3563 4.06742C16.8214 3.87427 17.0961 4.11004 16.9689 4.59692L14.1253 15.4847C13.9985 15.9703 13.5515 16.1438 13.1241 15.8705L10.0773 13.9219C9.8629 13.7848 9.56272 13.8345 9.40985 14.0292L8.41215 15.2997C8.10197 15.6946 7.71724 15.6311 7.5525 15.1567L6.67584 12.6326C6.51125 12.1587 6.01424 11.5902 5.55821 11.359L3.33271 10.2306Z" />
</SvgContainer>
);
};

4
src/assets/svgs/Sort.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.3347 0.271977C14.0797 0.0885134 13.79 0 13.5034 0C13.0191 0 12.5424 0.251056 12.2542 0.711326L12.0008 1.11366L10.6942 3.20097L9.44204 5.19976C9.15388 5.66003 9 6.19916 9 6.75116V14.3987C9 15.2822 9.67136 16 10.4996 16C10.9145 16 11.2902 15.8214 11.5602 15.5301C11.8318 15.2404 11.9992 14.8397 11.9992 14.3987V7.57353C11.9992 7.11809 12.1275 6.6723 12.3628 6.29411L14.7465 2.48964C14.917 2.21605 15 1.90706 15 1.60129C15 1.08469 14.7646 0.577751 14.3332 0.270368L14.3347 0.271977Z" fill="white"/>
<path d="M4.30727 3.20032L3.00075 1.11344L2.74881 0.711183C2.46065 0.251006 1.98391 0 1.49962 0C1.21297 0 0.923309 0.0884956 0.668343 0.271923C0.235353 0.579244 0 1.08608 0 1.60257C0 1.90829 0.0829771 2.21722 0.254966 2.49075L2.63716 6.29445C2.87403 6.67257 3.00075 7.11826 3.00075 7.57361V14.399C3.00075 15.2824 3.67211 16 4.50038 16C5.32864 16 6 15.2824 6 14.399V6.75141C6 6.19952 5.84762 5.6605 5.55947 5.20032L4.30576 3.20193L4.30727 3.20032Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,6 @@
export interface SVGProps {
color: string
height: string
width: string
opacity?: number
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,208 @@
import { getBaseApi } from "../background";
import { createSymmetricKeyAndNonce, decryptGroupData, encryptDataGroup, objectToBase64 } from "../qdn/encryption/group-encryption";
import { publishData } from "../qdn/publish/pubish";
const apiEndpoints = [
"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",
];
async function findUsableApi() {
for (const endpoint of apiEndpoints) {
try {
const response = await fetch(`${endpoint}/admin/status`);
if (!response.ok) throw new Error("Failed to fetch");
const data = await response.json();
if (data.isSynchronizing === false && data.syncPercent === 100) {
console.log(`Usable API found: ${endpoint}`);
return endpoint;
} else {
console.log(`API not ready: ${endpoint}`);
}
} catch (error) {
console.error(`Error checking API ${endpoint}:`, error);
}
}
throw new Error("No usable API found");
}
async function getSaveWallet() {
const res = await chrome.storage.local.get(["walletInfo"]);
if (res?.walletInfo) {
return res.walletInfo;
} else {
throw new Error("No wallet saved");
}
}
async function getNameInfo() {
const wallet = await getSaveWallet();
const address = wallet.address0;
const validApi = await getBaseApi()
const response = await fetch(validApi + "/names/address/" + address);
const nameData = await response.json();
if (nameData?.length > 0) {
return nameData[0].name;
} else {
return "";
}
}
async function getKeyPair() {
const res = await chrome.storage.local.get(["keyPair"]);
if (res?.keyPair) {
return res.keyPair;
} else {
throw new Error("Wallet not authenticated");
}
}
const getPublicKeys = async (groupNumber: number) => {
const validApi = await getBaseApi()
const response = await fetch(`${validApi}/groups/members/${groupNumber}?limit=0`);
const groupData = await response.json();
let members: any = [];
if (groupData && Array.isArray(groupData?.members)) {
for (const member of groupData.members) {
if (member.member) {
const resAddress = await fetch(`${validApi}/addresses/${member.member}`);
const resData = await resAddress.json();
const publicKey = resData.publicKey;
members.push(publicKey)
}
}
}
return members
}
export const encryptAndPublishSymmetricKeyGroupChat = async ({groupId, previousData}: {
groupId: number,
previousData: Object,
}) => {
try {
let highestKey = 0
if(previousData){
highestKey = Math.max(...Object.keys((previousData || {})).filter(item=> !isNaN(+item)).map(Number));
}
const resKeyPair = await getKeyPair()
const parsedData = JSON.parse(resKeyPair)
const privateKey = parsedData.privateKey
const userPublicKey = parsedData.publicKey
const groupmemberPublicKeys = await getPublicKeys(groupId)
const symmetricKey = createSymmetricKeyAndNonce()
const nextNumber = highestKey + 1
const objectToSave = {
...previousData,
[nextNumber]: symmetricKey
}
const symmetricKeyAndNonceBase64 = await objectToBase64(objectToSave)
const encryptedData = encryptDataGroup({
data64: symmetricKeyAndNonceBase64,
publicKeys: groupmemberPublicKeys,
privateKey,
userPublicKey
})
if(encryptedData){
const registeredName = await getNameInfo()
const data = await publishData({
registeredName, file: encryptedData, service: 'DOCUMENT_PRIVATE', identifier: `symmetric-qchat-group-${groupId}`, uploadType: 'file', isBase64: true, withFee: true
})
return {
data,
numberOfMembers: groupmemberPublicKeys.length
}
} else {
throw new Error('Cannot encrypt content')
}
} catch (error: any) {
throw new Error(error.message);
}
}
export const publishGroupEncryptedResource = async ({encryptedData, identifier}) => {
try {
if(encryptedData && identifier){
const registeredName = await getNameInfo()
if(!registeredName) throw new Error('You need a name to publish')
const data = await publishData({
registeredName, file: encryptedData, service: 'DOCUMENT', identifier, uploadType: 'file', isBase64: true, withFee: true
})
return data
} else {
throw new Error('Cannot encrypt content')
}
} catch (error: any) {
throw new Error(error.message);
}
}
export function uint8ArrayToBase64(uint8Array: any) {
const length = uint8Array.length
let binaryString = ''
const chunkSize = 1024 * 1024; // Process 1MB at a time
for (let i = 0; i < length; i += chunkSize) {
const chunkEnd = Math.min(i + chunkSize, length)
const chunk = uint8Array.subarray(i, chunkEnd)
// @ts-ignore
binaryString += Array.from(chunk, byte => String.fromCharCode(byte)).join('')
}
return btoa(binaryString)
}
export function base64ToUint8Array(base64: string) {
const binaryString = atob(base64)
const len = binaryString.length
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes
}
export const decryptGroupEncryption = async ({data}: {
data: string
}) => {
try {
const resKeyPair = await getKeyPair()
const parsedData = JSON.parse(resKeyPair)
const privateKey = parsedData.privateKey
const encryptedData = decryptGroupData(
data,
privateKey,
)
return {
data: uint8ArrayToBase64(encryptedData.decryptedData),
count: encryptedData.count
}
} catch (error: any) {
throw new Error(error.message);
}
}
export function uint8ArrayToObject(uint8Array: any) {
// Decode the byte array using TextDecoder
const decoder = new TextDecoder()
const jsonString = decoder.decode(uint8Array)
// Convert the JSON string back into an object
return JSON.parse(jsonString)
}

View File

@ -0,0 +1,7 @@
import React from 'react'
import './customloader.css'
export const CustomLoader = () => {
return (
<div className="lds-ellipsis"><div></div><div></div><div></div><div></div></div>
)
}

47
src/common/LazyLoad.tsx Normal file
View File

@ -0,0 +1,47 @@
import React, { useState, useEffect, useRef } from 'react'
import { useInView } from 'react-intersection-observer'
import CircularProgress from '@mui/material/CircularProgress'
interface Props {
onLoadMore: () => Promise<void>
}
const LazyLoad: React.FC<Props> = ({ onLoadMore }) => {
const [isFetching, setIsFetching] = useState<boolean>(false)
const firstLoad = useRef(false)
const [ref, inView] = useInView({
threshold: 0.7
})
useEffect(() => {
if (inView) {
setIsFetching(true)
onLoadMore().finally(() => {
setIsFetching(false)
firstLoad.current = true
})
}
}, [inView])
return (
<div
ref={ref}
style={{
display: 'flex',
justifyContent: 'center',
minHeight: '25px'
}}
>
<div
style={{
visibility: isFetching ? 'visible' : 'hidden'
}}
>
<CircularProgress />
</div>
</div>
)
}
export default LazyLoad

View File

@ -0,0 +1,64 @@
.lds-ellipsis {
color: white
}
.lds-ellipsis,
.lds-ellipsis div {
box-sizing: border-box;
}
.lds-ellipsis {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ellipsis div {
position: absolute;
top: 33.33333px;
width: 13.33333px;
height: 13.33333px;
border-radius: 50%;
background: currentColor;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 32px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 56px;
animation: lds-ellipsis3 0.6s infinite;
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}

64
src/common/useModal.tsx Normal file
View File

@ -0,0 +1,64 @@
import { useRef, useState } from 'react';
interface State {
isShow: boolean;
}
export const useModal = () => {
const [state, setState] = useState<State>({
isShow: false,
});
const [message, setMessage] = useState({
publishFee: "",
message: ""
});
const promiseConfig = useRef<any>(null);
const show = async (data) => {
setMessage(data)
return new Promise((resolve, reject) => {
promiseConfig.current = {
resolve,
reject,
};
setState({
isShow: true,
});
});
};
const hide = () => {
setState({
isShow: false,
});
setMessage({
publishFee: "",
message: ""
})
};
const onOk = (payload:any) => {
const { resolve } = promiseConfig.current;
setMessage({
publishFee: "",
message: ""
})
hide();
resolve(payload);
};
const onCancel = () => {
const { reject } = promiseConfig.current;
hide();
reject();
setMessage({
publishFee: "",
message: ""
})
};
return {
show,
onOk,
onCancel,
isShow: state.isShow,
message
};
};

View File

@ -0,0 +1,344 @@
import React, { useMemo, useRef, useState } from "react";
import TipTap from "./TipTap";
import { AuthenticatedContainerInnerTop, CustomButton } from "../../App-styles";
import { Box, CircularProgress } from "@mui/material";
import { objectToBase64 } from "../../qdn/encryption/group-encryption";
import ShortUniqueId from "short-unique-id";
import { LoadingSnackbar } from "../Snackbar/LoadingSnackbar";
import { getBaseApi, getFee } from "../../background";
import { decryptPublishes, getTempPublish, saveTempPublish } from "./GroupAnnouncements";
import { AnnouncementList } from "./AnnouncementList";
import { Spacer } from "../../common/Spacer";
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { getBaseApiReact } from "../../App";
const tempKey = 'accouncement-comment'
const uid = new ShortUniqueId({ length: 8 });
export const AnnouncementDiscussion = ({
getSecretKey,
encryptChatMessage,
selectedAnnouncement,
secretKey,
setSelectedAnnouncement,
show,
myName
}) => {
const [isSending, setIsSending] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [comments, setComments] = useState([])
const [tempPublishedList, setTempPublishedList] = useState([])
const firstMountRef = useRef(false)
const [data, setData] = useState({})
const editorRef = useRef(null);
const setEditorRef = (editorInstance) => {
editorRef.current = editorInstance;
};
const clearEditorContent = () => {
if (editorRef.current) {
editorRef.current.chain().focus().clearContent().run();
}
};
const getData = async ({ identifier, name }) => {
try {
const res = await fetch(
`${getBaseApiReact()}/arbitrary/DOCUMENT/${name}/${identifier}?encoding=base64`
);
const data = await res.text();
const response = await decryptPublishes([{ data }], secretKey);
const messageData = response[0];
setData((prev) => {
return {
...prev,
[`${identifier}-${name}`]: messageData,
};
});
} catch (error) {}
};
const publishAnc = async ({ encryptedData, identifier }: any) => {
try {
if (!selectedAnnouncement) return;
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "publishGroupEncryptedResource",
payload: {
encryptedData,
identifier,
},
},
(response) => {
if (!response?.error) {
res(response);
}
rej(response.error);
}
);
});
} catch (error) {}
};
const setTempData = async ()=> {
try {
const getTempAnnouncements = await getTempPublish()
if(getTempAnnouncements[tempKey]){
let tempData = []
Object.keys(getTempAnnouncements[tempKey] || {}).map((key)=> {
const value = getTempAnnouncements[tempKey][key]
if(value.data?.announcementId === selectedAnnouncement.identifier){
tempData.push(value.data)
}
})
setTempPublishedList(tempData)
}
} catch (error) {
}
}
const publishComment = async () => {
try {
const fee = await getFee('ARBITRARY')
await show({
message: "Would you like to perform a ARBITRARY transaction?" ,
publishFee: fee.fee + ' QORT'
})
if (isSending) return;
if (editorRef.current) {
const htmlContent = editorRef.current.getHTML();
if (!htmlContent?.trim() || htmlContent?.trim() === "<p></p>") return;
setIsSending(true);
const message = {
version: 1,
extra: {},
message: htmlContent,
};
const secretKeyObject = await getSecretKey();
const message64: any = await objectToBase64(message);
const encryptSingle = await encryptChatMessage(
message64,
secretKeyObject
);
const randomUid = uid.rnd();
const identifier = `cm-${selectedAnnouncement.identifier}-${randomUid}`;
const res = await publishAnc({
encryptedData: encryptSingle,
identifier
});
const dataToSaveToStorage = {
name: myName,
identifier,
service: 'DOCUMENT',
tempData: message,
created: Date.now(),
announcementId: selectedAnnouncement.identifier
}
await saveTempPublish({data: dataToSaveToStorage, key: tempKey})
setTempData()
clearEditorContent();
}
// send chat message
} catch (error) {
console.error(error);
} finally {
setIsSending(false);
}
};
const getComments = React.useCallback(
async (selectedAnnouncement) => {
try {
setIsLoading(true);
const offset = 0;
// dispatch(setIsLoadingGlobal(true))
const identifier = `cm-${selectedAnnouncement.identifier}`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=20&includemetadata=false&offset=${offset}&reverse=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
setTempData()
setComments(responseData);
setIsLoading(false);
for (const data of responseData) {
getData({ name: data.name, identifier: data.identifier });
}
} catch (error) {
} finally {
setIsLoading(false);
// dispatch(setIsLoadingGlobal(false))
}
},
[secretKey]
);
const loadMore = async()=> {
try {
setIsLoading(true);
const offset = comments.length
const identifier = `cm-${selectedAnnouncement.identifier}`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=20&includemetadata=false&offset=${offset}&reverse=true&prefix=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
setComments((prev)=> [...prev, ...responseData]);
setIsLoading(false);
for (const data of responseData) {
getData({ name: data.name, identifier: data.identifier });
}
} catch (error) {
}
}
const combinedListTempAndReal = useMemo(() => {
// Combine the two lists
const combined = [...tempPublishedList, ...comments];
// Remove duplicates based on the "identifier"
const uniqueItems = new Map();
combined.forEach(item => {
uniqueItems.set(item.identifier, item); // This will overwrite duplicates, keeping the last occurrence
});
// 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.created - a.created);
return sortedList;
}, [tempPublishedList, comments]);
React.useEffect(() => {
if (selectedAnnouncement && secretKey && !firstMountRef.current) {
getComments(selectedAnnouncement);
firstMountRef.current = true
}
}, [selectedAnnouncement, secretKey]);
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<div style={{
position: "relative",
width: "100%",
display: "flex",
flexDirection: "column",
flexShrink: 0,
}}>
<AuthenticatedContainerInnerTop>
<ArrowBackIcon onClick={()=> setSelectedAnnouncement(null)} sx={{
cursor: 'pointer'
}} />
</AuthenticatedContainerInnerTop>
<Spacer height="20px" />
</div>
<AnnouncementList
announcementData={data}
initialMessages={combinedListTempAndReal}
setSelectedAnnouncement={()=> {}}
disableComment
showLoadMore={comments.length > 0 && comments.length % 20 === 0}
loadMore={loadMore}
/>
<div
style={{
// position: 'fixed',
// bottom: '0px',
backgroundColor: "#232428",
minHeight: "150px",
maxHeight: "400px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
width: "100%",
boxSizing: "border-box",
padding: "20px",
flexShrink:0,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
// height: '100%',
overflow: "auto",
}}
>
<TipTap
setEditorRef={setEditorRef}
onEnter={publishComment}
disableEnter
/>
</div>
<CustomButton
onClick={() => {
if (isSending) return;
publishComment();
}}
style={{
marginTop: "auto",
alignSelf: "center",
cursor: isSending ? "default" : "pointer",
background: isSending && "rgba(0, 0, 0, 0.8)",
flexShrink: 0,
}}
>
{isSending && (
<CircularProgress
size={18}
sx={{
position: "absolute",
top: "50%",
left: "50%",
marginTop: "-12px",
marginLeft: "-12px",
color: "white",
}}
/>
)}
{` Publish Comment`}
</CustomButton>
</div>
<LoadingSnackbar
open={isLoading}
info={{
message: "Loading comments... please wait.",
}}
/>
</div>
);
};

View File

@ -0,0 +1,167 @@
import { Message } from "@chatscope/chat-ui-kit-react";
import React, { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import { MessageDisplay } from "./MessageDisplay";
import { Avatar, Box, Typography } from "@mui/material";
import { formatTimestamp } from "../../utils/time";
import ChatBubbleIcon from '@mui/icons-material/ChatBubble';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import { getBaseApi } from "../../background";
import { requestQueueCommentCount } from "./GroupAnnouncements";
import { CustomLoader } from "../../common/CustomLoader";
import { getBaseApiReact } from "../../App";
export const AnnouncementItem = ({ message, messageData, setSelectedAnnouncement, disableComment }) => {
const [commentLength, setCommentLength] = useState(0)
const getNumberOfComments = React.useCallback(
async () => {
try {
const offset = 0;
// dispatch(setIsLoadingGlobal(true))
const identifier = `cm-${message.identifier}`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=0&includemetadata=false&offset=${offset}&reverse=true`;
const response = await requestQueueCommentCount.enqueue(() => {
return fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
})
const responseData = await response.json();
setCommentLength(responseData?.length);
} catch (error) {
} finally {
// dispatch(setIsLoadingGlobal(false))
}
},
[]
);
useEffect(()=> {
if(disableComment) return
getNumberOfComments()
}, [])
return (
<div
style={{
padding: "10px",
backgroundColor: "#232428",
borderRadius: "7px",
width: "95%",
display: "flex",
gap: '7px',
flexDirection: 'column'
}}
>
<Box sx={{
display: "flex",
gap: '7px',
width: '100%'
}}>
<Avatar
sx={{
backgroundColor: '#27282c',
color: 'white'
}}
alt={message?.name}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${message?.name}/qortal_avatar?async=true`}
>
{message?.name?.charAt(0)}
</Avatar>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "7px",
width: '100%'
}}
>
<Typography
sx={{
fontWight: 600,
fontFamily: "Inter",
color: "cadetBlue",
}}
>
{message?.name}
</Typography>
{!messageData?.decryptedData && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<CustomLoader />
</Box>
)}
{messageData?.decryptedData?.message && (
<>
{messageData?.type === "notification" ? (
<MessageDisplay htmlContent={messageData?.decryptedData?.message} />
) : (
<MessageDisplay htmlContent={messageData?.decryptedData?.message} />
)}
</>
)}
<Box sx={{
display: 'flex',
justifyContent: 'flex-end',
width: '100%'
}}>
<Typography sx={{
fontSize: '14px',
color: 'gray',
fontFamily: 'Inter'
}}>{formatTimestamp(message.created)}</Typography>
</Box>
</Box>
</Box>
{!disableComment && (
<Box sx={{
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'space-between',
padding: '20px',
cursor: 'pointer',
opacity: 0.4,
borderTop: '1px solid white',
}} onClick={()=> setSelectedAnnouncement(message)}>
<Box sx={{
display: 'flex',
width: '100%',
gap: '25px',
alignItems: 'center',
}}>
<ChatBubbleIcon sx={{
fontSize: '20px'
}} />
{commentLength ? (
<Typography sx={{
fontSize: '14px'
}}>{`${commentLength > 1 ? `${commentLength} comments` : `${commentLength} comment`}`}</Typography>
) : (
<Typography sx={{
fontSize: '14px'
}}>Leave comment</Typography>
)}
</Box>
<ArrowForwardIosIcon sx={{
fontSize: '20px'
}} />
</Box>
)}
</div>
);
};

View File

@ -0,0 +1,96 @@
import React, { useCallback, useState, useEffect, useRef } from "react";
import {
List,
AutoSizer,
CellMeasurerCache,
CellMeasurer,
} from "react-virtualized";
import { AnnouncementItem } from "./AnnouncementItem";
import { Box } from "@mui/material";
import { CustomButton } from "../../App-styles";
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const AnnouncementList = ({
initialMessages,
announcementData,
setSelectedAnnouncement,
disableComment,
showLoadMore,
loadMore
}) => {
const listRef = useRef();
const [messages, setMessages] = useState(initialMessages);
useEffect(() => {
cache.clearAll();
}, []);
useEffect(() => {
setMessages(initialMessages);
}, [initialMessages]);
return (
<div
style={{
position: "relative",
flexGrow: 1,
width: "100%",
display: "flex",
flexDirection: "column",
flexShrink: 1,
overflow: 'auto'
}}
>
{messages.map((message) => {
const messageData = message?.tempData ? {
decryptedData: message?.tempData
} : announcementData[`${message.identifier}-${message.name}`];
return (
<div
style={{
marginBottom: "10px",
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<AnnouncementItem disableComment={disableComment} setSelectedAnnouncement={setSelectedAnnouncement} message={message} messageData={messageData} />
</div>
);
})}
{/* <AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={messages.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer> */}
<Box sx={{
width: '100%',
marginTop: '25px',
display: 'flex',
justifyContent: 'center'
}}>
{showLoadMore && (
<CustomButton onClick={loadMore}>Load older announcements</CustomButton>
)}
</Box>
</div>
);
};

View File

@ -0,0 +1,56 @@
import React, { useState } from "react";
import InfiniteScroll from "react-infinite-scroller";
import {
MainContainer,
ChatContainer,
MessageList,
Message,
MessageInput,
Avatar
} from "@chatscope/chat-ui-kit-react";
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
export const ChatContainerComp = ({messages}) => {
// const [messages, setMessages] = useState([
// { id: 1, text: "Hello! How are you?", sender: "Joe"},
// { id: 2, text: "I'm good, thank you!", sender: "Me" }
// ]);
// const loadMoreMessages = () => {
// // Simulate loading more messages (you could fetch these from an API)
// const moreMessages = [
// { id: 3, text: "What about you?", sender: "Joe", direction: "incoming" },
// { id: 4, text: "I'm great, thanks!", sender: "Me", direction: "outgoing" }
// ];
// setMessages((prevMessages) => [...moreMessages, ...prevMessages]);
// };
return (
<div style={{ height: "500px", width: "300px" }}>
<MainContainer>
<ChatContainer>
<MessageList>
{messages.map((msg) => (
<Message
key={msg.id}
model={{
message: msg.text,
sentTime: "just now",
sender: msg.senderName,
direction: 'incoming',
position: "single"
}}
>
{msg.direction === "incoming" && <Avatar name={msg.senderName} />}
</Message>
))}
</MessageList>
<MessageInput placeholder="Type a message..." />
</ChatContainer>
</MainContainer>
</div>
);
};

View File

@ -0,0 +1,305 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { objectToBase64 } from '../../qdn/encryption/group-encryption'
import { ChatList } from './ChatList'
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import Tiptap from './TipTap'
import { CustomButton } from '../../App-styles'
import CircularProgress from '@mui/material/CircularProgress';
import { Input } from '@mui/material';
import { LoadingSnackbar } from '../Snackbar/LoadingSnackbar';
import { getNameInfo } from '../Group/Group';
import { Spacer } from '../../common/Spacer';
import { CustomizedSnackbars } from '../Snackbar/Snackbar';
import { getBaseApiReactSocket } from '../../App';
export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDirect, setNewChat, getTimestampEnterChat, myName}) => {
const [messages, setMessages] = useState([])
const [isSending, setIsSending] = useState(false)
const [directToValue, setDirectToValue] = useState('')
const hasInitialized = useRef(false)
const [isLoading, setIsLoading] = useState(false)
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const hasInitializedWebsocket = useRef(false)
const editorRef = useRef(null);
const setEditorRef = (editorInstance) => {
editorRef.current = editorInstance;
};
const decryptMessages = (encryptedMessages: any[])=> {
try {
return new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "decryptDirect", payload: {
data: encryptedMessages,
involvingAddress: selectedDirect?.address
}}, (response) => {
if (!response?.error) {
res(response)
if(hasInitialized.current){
const formatted = response.map((item: any)=> {
return {
...item,
id: item.signature,
text: item.message,
unread: true
}
} )
setMessages((prev)=> [...prev, ...formatted])
} else {
const formatted = response.map((item: any)=> {
return {
...item,
id: item.signature,
text: item.message,
unread: false
}
} )
setMessages(formatted)
hasInitialized.current = true
}
}
rej(response.error)
});
})
} catch (error) {
}
}
const initWebsocketMessageGroup = () => {
let timeoutId
let groupSocketTimeout
let socketTimeout: any
let socketLink = `${getBaseApiReactSocket()}/websockets/chat/messages?involving=${selectedDirect?.address}&involving=${myAddress}&encoding=BASE64&limit=100`
const socket = new WebSocket(socketLink)
const pingGroupSocket = () => {
socket.send('ping')
timeoutId = setTimeout(() => {
socket.close()
clearTimeout(groupSocketTimeout)
}, 5000) // Close the WebSocket connection if no pong message is received within 5 seconds.
}
socket.onopen = () => {
setTimeout(pingGroupSocket, 50)
}
socket.onmessage = (e) => {
try {
if (e.data === 'pong') {
clearTimeout(timeoutId)
groupSocketTimeout = setTimeout(pingGroupSocket, 45000)
return
} else {
decryptMessages(JSON.parse(e.data))
setIsLoading(false)
}
} catch (error) {
}
}
socket.onclose = () => {
console.log('closed')
clearTimeout(socketTimeout)
setTimeout(() => initWebsocketMessageGroup(), 50)
}
socket.onerror = (e) => {
clearTimeout(groupSocketTimeout)
socket.close()
}
}
useEffect(()=> {
if(hasInitializedWebsocket.current) return
setIsLoading(true)
initWebsocketMessageGroup()
hasInitializedWebsocket.current = true
}, [])
const sendChatDirect = async ({ chatReference = undefined, messageText}: any)=> {
try {
const directTo = isNewChat ? directToValue : selectedDirect.address
if(!directTo) return
return new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "sendChatDirect", payload: {
directTo, chatReference, messageText
}}, async (response) => {
if (!response?.error) {
if(isNewChat){
let getRecipientName = null
try {
getRecipientName = await getNameInfo(response.recipient)
} catch (error) {
}
setSelectedDirect({
"address": response.recipient,
"name": getRecipientName,
"timestamp": Date.now(),
"sender": myAddress,
"senderName": myName
})
setNewChat(null)
chrome.runtime.sendMessage({
action: "addTimestampEnterChat",
payload: {
timestamp: Date.now(),
groupId: response.recipient,
},
});
setTimeout(() => {
getTimestampEnterChat()
}, 400);
}
res(response)
return
}
rej(response.error)
});
})
} catch (error) {
throw new Error(error)
}
}
const clearEditorContent = () => {
if (editorRef.current) {
editorRef.current.chain().focus().clearContent().run();
}
};
const sendMessage = async ()=> {
try {
if(isSending) return
if (editorRef.current) {
const htmlContent = editorRef.current.getHTML();
if(!htmlContent?.trim() || htmlContent?.trim() === '<p></p>') return
setIsSending(true)
const message = JSON.stringify(htmlContent)
const res = await sendChatDirect({ messageText: htmlContent})
clearEditorContent()
}
// send chat message
} catch (error) {
setInfoSnack({
type: "error",
message: error,
});
setOpenSnack(true);
console.error(error)
} finally {
setIsSending(false)
}
}
return (
<div style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
width: '100%'
}}>
{isNewChat && (
<>
<Spacer height="30px" />
<Input sx={{
fontSize: '18px'
}} placeholder='Name or address' value={directToValue} onChange={(e)=> setDirectToValue(e.target.value)} />
</>
)}
<ChatList initialMessages={messages} myAddress={myAddress}/>
<div style={{
// position: 'fixed',
// bottom: '0px',
backgroundColor: "#232428",
minHeight: '150px',
maxHeight: '400px',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
width: '100%',
boxSizing: 'border-box',
padding: '20px'
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
// height: '100%',
overflow: 'auto'
}}>
<Tiptap setEditorRef={setEditorRef} onEnter={sendMessage} />
</div>
<CustomButton
onClick={()=> {
if(isSending) return
sendMessage()
}}
style={{
marginTop: 'auto',
alignSelf: 'center',
cursor: isSending ? 'default' : 'pointer',
background: isSending && 'rgba(0, 0, 0, 0.8)',
flexShrink: 0
}}
>
{isSending && (
<CircularProgress
size={18}
sx={{
position: 'absolute',
top: '50%',
left: '50%',
marginTop: '-12px',
marginLeft: '-12px',
color: 'white'
}}
/>
)}
{` Send`}
</CustomButton>
</div>
<LoadingSnackbar open={isLoading} info={{
message: "Loading chat... please wait."
}} />
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
</div>
)
}

View File

@ -0,0 +1,377 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CreateCommonSecret } from './CreateCommonSecret'
import { reusableGet } from '../../qdn/publish/pubish'
import { uint8ArrayToObject } from '../../backgroundFunctions/encryption'
import { base64ToUint8Array, objectToBase64 } from '../../qdn/encryption/group-encryption'
import { ChatContainerComp } from './ChatContainer'
import { ChatList } from './ChatList'
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import Tiptap from './TipTap'
import { CustomButton } from '../../App-styles'
import CircularProgress from '@mui/material/CircularProgress';
import { LoadingSnackbar } from '../Snackbar/LoadingSnackbar'
import { getBaseApiReactSocket } from '../../App'
import { CustomizedSnackbars } from '../Snackbar/Snackbar'
import { PUBLIC_NOTIFICATION_CODE_FIRST_SECRET_KEY } from '../../constants/codes'
export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, myAddress, handleNewEncryptionNotification, hide, handleSecretKeyCreationInProgress, triedToFetchSecretKey}) => {
const [messages, setMessages] = useState([])
const [isSending, setIsSending] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isMoved, setIsMoved] = useState(false);
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const hasInitialized = useRef(false)
const hasInitializedWebsocket = useRef(false)
const socketRef = useRef(null); // WebSocket reference
const timeoutIdRef = useRef(null); // Timeout ID reference
const groupSocketTimeoutRef = useRef(null); // Group Socket Timeout reference
const editorRef = useRef(null);
const setEditorRef = (editorInstance) => {
editorRef.current = editorInstance;
};
const secretKeyRef = useRef(null)
useEffect(()=> {
if(secretKey){
secretKeyRef.current = secretKey
}
}, [secretKey])
// const getEncryptedSecretKey = useCallback(()=> {
// const response = getResource()
// const decryptResponse = decryptResource()
// return
// }, [])
const checkForFirstSecretKeyNotification = (messages)=> {
messages?.forEach((message)=> {
try {
const decodeMsg = atob(message.data);
if(decodeMsg === PUBLIC_NOTIFICATION_CODE_FIRST_SECRET_KEY){
handleSecretKeyCreationInProgress()
return
}
} catch (error) {
}
})
}
const decryptMessages = (encryptedMessages: any[])=> {
try {
if(!secretKeyRef.current){
checkForFirstSecretKeyNotification(encryptedMessages)
return
}
return new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "decryptSingle", payload: {
data: encryptedMessages,
secretKeyObject: secretKey
}}, (response) => {
if (!response?.error) {
res(response)
if(hasInitialized.current){
const formatted = response.map((item: any)=> {
return {
...item,
id: item.signature,
text: item.text,
unread: true
}
} )
setMessages((prev)=> [...prev, ...formatted])
} else {
const formatted = response.map((item: any)=> {
return {
...item,
id: item.signature,
text: item.text,
unread: false
}
} )
setMessages(formatted)
hasInitialized.current = true
}
}
rej(response.error)
});
})
} catch (error) {
}
}
const forceCloseWebSocket = () => {
if (socketRef.current) {
clearTimeout(timeoutIdRef.current);
clearTimeout(groupSocketTimeoutRef.current);
socketRef.current.close(1000, 'forced');
socketRef.current = null;
}
};
const pingGroupSocket = () => {
try {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send('ping');
timeoutIdRef.current = setTimeout(() => {
if (socketRef.current) {
socketRef.current.close();
clearTimeout(groupSocketTimeoutRef.current);
}
}, 5000); // Close if no pong in 5 seconds
}
} catch (error) {
console.error('Error during ping:', error);
}
}
const initWebsocketMessageGroup = () => {
let socketLink = `${getBaseApiReactSocket()}/websockets/chat/messages?txGroupId=${selectedGroup}&encoding=BASE64&limit=100`
socketRef.current = new WebSocket(socketLink)
socketRef.current.onopen = () => {
setTimeout(pingGroupSocket, 50)
}
socketRef.current.onmessage = (e) => {
try {
if (e.data === 'pong') {
clearTimeout(timeoutIdRef.current);
groupSocketTimeoutRef.current = setTimeout(pingGroupSocket, 45000); // Ping every 45 seconds
} else {
decryptMessages(JSON.parse(e.data))
setIsLoading(false)
}
} catch (error) {
}
}
socketRef.current.onclose = () => {
clearTimeout(groupSocketTimeoutRef.current);
clearTimeout(timeoutIdRef.current);
console.warn(`WebSocket closed: ${event.reason || 'unknown reason'}`);
if (event.reason !== 'forced' && event.code !== 1000) {
setTimeout(() => initWebsocketMessageGroup(), 1000); // Retry after 10 seconds
}
}
socketRef.current.onerror = (e) => {
console.error('WebSocket error:', error);
clearTimeout(groupSocketTimeoutRef.current);
clearTimeout(timeoutIdRef.current);
if (socketRef.current) {
socketRef.current.close();
}
}
}
useEffect(()=> {
if(hasInitializedWebsocket.current) return
if(triedToFetchSecretKey && !secretKey){
forceCloseWebSocket()
setMessages([])
setIsLoading(true)
initWebsocketMessageGroup()
}
}, [triedToFetchSecretKey, secretKey])
useEffect(()=> {
if(!secretKey || hasInitializedWebsocket.current) return
forceCloseWebSocket()
setMessages([])
setIsLoading(true)
initWebsocketMessageGroup()
hasInitializedWebsocket.current = true
}, [secretKey])
useEffect(()=> {
const notifications = messages.filter((message)=> message?.text?.type === 'notification')
if(notifications.length === 0) return
const latestNotification = notifications.reduce((latest, current) => {
return current.timestamp > latest.timestamp ? current : latest;
}, notifications[0]);
handleNewEncryptionNotification(latestNotification)
}, [messages])
const encryptChatMessage = async (data: string, secretKeyObject: any)=> {
try {
return new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "encryptSingle", payload: {
data,
secretKeyObject
}}, (response) => {
if (!response?.error) {
res(response)
}
rej(response.error)
});
})
} catch (error) {
}
}
const sendChatGroup = async ({groupId, typeMessage = undefined, chatReference = undefined, messageText}: any)=> {
try {
return new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "sendChatGroup", payload: {
groupId, typeMessage, chatReference, messageText
}}, (response) => {
if (!response?.error) {
res(response)
return
}
rej(response.error)
});
})
} catch (error) {
throw new Error(error)
}
}
const clearEditorContent = () => {
if (editorRef.current) {
editorRef.current.chain().focus().clearContent().run();
}
};
const sendMessage = async ()=> {
try {
if(isSending) return
if (editorRef.current) {
const htmlContent = editorRef.current.getHTML();
if(!htmlContent?.trim() || htmlContent?.trim() === '<p></p>') return
setIsSending(true)
const message = htmlContent
const secretKeyObject = await getSecretKey()
const message64: any = await objectToBase64(message)
const encryptSingle = await encryptChatMessage(message64, secretKeyObject)
const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle})
clearEditorContent()
}
// send chat message
} catch (error) {
setInfoSnack({
type: "error",
message: error,
});
setOpenSnack(true);
console.error(error)
} finally {
setIsSending(false)
}
}
useEffect(() => {
if (hide) {
setTimeout(() => setIsMoved(true), 500); // Wait for the fade-out to complete before moving
} else {
setIsMoved(false); // Reset the position immediately when showing
}
}, [hide]);
return (
<div style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
width: '100%',
opacity: hide ? 0 : 1,
visibility: hide && 'hidden',
position: hide ? 'fixed' : 'relative',
left: hide && '-1000px',
}}>
<ChatList initialMessages={messages} myAddress={myAddress}/>
<div style={{
// position: 'fixed',
// bottom: '0px',
backgroundColor: "#232428",
minHeight: '150px',
maxHeight: '400px',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
width: '100%',
boxSizing: 'border-box',
padding: '20px'
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
// height: '100%',
overflow: 'auto'
}}>
<Tiptap setEditorRef={setEditorRef} onEnter={sendMessage} />
</div>
<CustomButton
onClick={()=> {
if(isSending) return
sendMessage()
}}
style={{
marginTop: 'auto',
alignSelf: 'center',
cursor: isSending ? 'default' : 'pointer',
background: isSending && 'rgba(0, 0, 0, 0.8)',
flexShrink: 0
}}
>
{isSending && (
<CircularProgress
size={18}
sx={{
position: 'absolute',
top: '50%',
left: '50%',
marginTop: '-12px',
marginLeft: '-12px',
color: 'white'
}}
/>
)}
{` Send`}
</CustomButton>
{/* <button onClick={sendMessage}>send</button> */}
</div>
{/* <ChatContainerComp messages={formatMessages} /> */}
<LoadingSnackbar open={isLoading} info={{
message: "Loading chat... please wait."
}} />
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
</div>
)
}

View File

@ -0,0 +1,144 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { List, AutoSizer, CellMeasurerCache, CellMeasurer } from 'react-virtualized';
import { MessageItem } from './MessageItem';
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const ChatList = ({ initialMessages, myAddress }) => {
const hasLoadedInitialRef = useRef(false);
const listRef = useRef();
const [messages, setMessages] = useState(initialMessages);
const [showScrollButton, setShowScrollButton] = useState(false);
useEffect(()=> {
cache.clearAll();
}, [])
const handleMessageSeen = useCallback((messageId) => {
setMessages((prevMessages) =>
prevMessages.map((msg) =>
msg.id === messageId ? { ...msg, unread: false } : msg
)
);
}, []);
const handleScroll = ({ scrollTop, scrollHeight, clientHeight }) => {
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50;
const hasUnreadMessages = messages.some((msg) => msg.unread);
if (!isAtBottom && hasUnreadMessages) {
setShowScrollButton(true);
} else {
setShowScrollButton(false);
}
};
const scrollToBottom = () => {
if (listRef.current) {
listRef.current?.recomputeRowHeights();
listRef.current.scrollToRow(messages.length - 1);
setTimeout(() => {
listRef.current?.recomputeRowHeights();
listRef.current.scrollToRow(messages.length - 1);
}, 100);
setShowScrollButton(false);
}
};
const rowRenderer = ({ index, key, parent, style }) => {
const message = messages[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style}>
<div onLoad={measure} style={{
marginBottom: '10px',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}>
<MessageItem message={message} onSeen={handleMessageSeen} />
</div>
</div>
)}
</CellMeasurer>
);
};
useEffect(() => {
setMessages(initialMessages);
setTimeout(() => {
if (listRef.current) {
// Accessing scrollTop, scrollHeight, clientHeight from List's methods
const scrollTop = listRef.current.Grid._scrollingContainer.scrollTop;
const scrollHeight = listRef.current.Grid._scrollingContainer.scrollHeight;
const clientHeight = listRef.current.Grid._scrollingContainer.clientHeight;
handleScroll({ scrollTop, scrollHeight, clientHeight });
}
}, 100);
}, [initialMessages]);
useEffect(() => {
// Scroll to the bottom on initial load or when messages change
if (listRef.current && messages.length > 0 && hasLoadedInitialRef.current === false) {
scrollToBottom();
hasLoadedInitialRef.current = true;
} else if (messages.length > 0 && messages[messages.length - 1].sender === myAddress) {
scrollToBottom();
}
}, [messages, myAddress]);
return (
<div style={{ position: 'relative', flexGrow: 1, width: '100%', display: 'flex', flexDirection: 'column', flexShrink: 1 }}>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={messages.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
onScroll={handleScroll}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
{showScrollButton && (
<button
onClick={scrollToBottom}
style={{
position: 'absolute',
bottom: 20,
right: 20,
backgroundColor: '#ff5a5f',
color: 'white',
padding: '10px 20px',
borderRadius: '20px',
cursor: 'pointer',
zIndex: 10,
}}
>
Scroll to Unread Messages
</button>
)}
</div>
);
};

View File

@ -0,0 +1,79 @@
import { Box, Button, Typography } from '@mui/material'
import React, { useContext } from 'react'
import { CustomizedSnackbars } from '../Snackbar/Snackbar';
import { LoadingButton } from '@mui/lab';
import { MyContext } from '../../App';
import { getFee } from '../../background';
export const CreateCommonSecret = ({groupId, secretKey, isOwner, myAddress, secretKeyDetails, userInfo, noSecretKey}) => {
const { show, setTxList } = useContext(MyContext);
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(false)
const createCommonSecret = async ()=> {
try {
const fee = await getFee('ARBITRARY')
await show({
message: "Would you like to perform an ARBITRARY transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoading(true)
chrome.runtime.sendMessage({ action: "encryptAndPublishSymmetricKeyGroupChat", payload: {
groupId: groupId,
previousData: secretKey
} }, (response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message: "Successfully re-encrypted secret key. It may take a couple of minutes for the changes to propagate. Refresh the group in 5mins",
});
setOpenSnack(true);
setTxList((prev)=> [{
...response,
type: 'created-common-secret',
label: `Published secret key for group ${groupId}: awaiting confirmation`,
labelDone: `Published secret key for group ${groupId}: success!`,
done: false,
groupId,
}, ...prev])
}
setIsLoading(false)
});
} catch (error) {
}
}
return (
<Box sx={{
padding: '25px',
display: 'flex',
flexDirection: 'column',
gap: '25px',
maxWidth: '350px',
background: '#4444'
}}>
<LoadingButton loading={isLoading} loadingPosition="start" color="warning" variant='contained' onClick={createCommonSecret}>Re-encyrpt key</LoadingButton>
{noSecretKey ? (
<Box>
<Typography>There is no group secret key. Be the first admin to publish one!</Typography>
</Box>
) : isOwner && secretKeyDetails && userInfo?.name && userInfo.name !== secretKeyDetails?.name ? (
<Box>
<Typography>The latest group secret key was published by a non-owner. As the owner of the group please re-encrypt the key as a safeguard</Typography>
</Box>
): (
<Box>
<Typography>The group member list has changed. Please re-encrypt the secret key.</Typography>
</Box>
)}
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
</Box>
)
}

View File

@ -0,0 +1,59 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import ResizableImage from './ResizableImage'; // Import your ResizableImage component
const CustomImage = Node.create({
name: 'image',
inline: false,
group: 'block',
draggable: true,
addAttributes() {
return {
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
width: {
default: 'auto',
},
};
},
parseHTML() {
return [
{
tag: 'img[src]',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['img', mergeAttributes(HTMLAttributes)];
},
addNodeView() {
return ReactNodeViewRenderer(ResizableImage);
},
addCommands() {
return {
setImage:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
export default CustomImage;

View File

@ -0,0 +1,607 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { CreateCommonSecret } from "./CreateCommonSecret";
import { reusableGet } from "../../qdn/publish/pubish";
import { uint8ArrayToObject } from "../../backgroundFunctions/encryption";
import {
base64ToUint8Array,
objectToBase64,
} from "../../qdn/encryption/group-encryption";
import { ChatContainerComp } from "./ChatContainer";
import { ChatList } from "./ChatList";
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import Tiptap from "./TipTap";
import { AuthenticatedContainerInnerTop, CustomButton } from "../../App-styles";
import CircularProgress from "@mui/material/CircularProgress";
import { getBaseApi, getFee } from "../../background";
import { LoadingSnackbar } from "../Snackbar/LoadingSnackbar";
import { Box, Typography } from "@mui/material";
import { Spacer } from "../../common/Spacer";
import ShortUniqueId from "short-unique-id";
import { AnnouncementList } from "./AnnouncementList";
const uid = new ShortUniqueId({ length: 8 });
import CampaignIcon from '@mui/icons-material/Campaign';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { AnnouncementDiscussion } from "./AnnouncementDiscussion";
import { MyContext, getBaseApiReact } from "../../App";
import { RequestQueueWithPromise } from "../../utils/queue/queue";
import { CustomizedSnackbars } from "../Snackbar/Snackbar";
export const requestQueueCommentCount = new RequestQueueWithPromise(3)
export const requestQueuePublishedAccouncements = new RequestQueueWithPromise(3)
export const saveTempPublish = async ({ data, key }: any) => {
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "saveTempPublish",
payload: {
data,
key,
},
},
(response) => {
if (!response?.error) {
res(response);
}
rej(response.error);
}
);
});
};
export const getTempPublish = async () => {
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "getTempPublish",
payload: {
},
},
(response) => {
if (!response?.error) {
res(response);
}
rej(response.error);
}
);
});
};
export const decryptPublishes = async (encryptedMessages: any[], secretKey) => {
try {
return await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "decryptSingleForPublishes",
payload: {
data: encryptedMessages,
secretKeyObject: secretKey,
skipDecodeBase64: true,
},
},
(response) => {
if (!response?.error) {
res(response);
// if(hasInitialized.current){
// setMessages((prev)=> [...prev, ...formatted])
// } else {
// const formatted = response.map((item: any)=> {
// return {
// ...item,
// id: item.signature,
// text: item.text,
// unread: false
// }
// } )
// setMessages(formatted)
// hasInitialized.current = true
// }
}
rej(response.error);
}
);
});
} catch (error) {}
};
export const GroupAnnouncements = ({
selectedGroup,
secretKey,
setSecretKey,
getSecretKey,
myAddress,
handleNewEncryptionNotification,
isAdmin,
hide,
myName
}) => {
const [messages, setMessages] = useState([]);
const [isSending, setIsSending] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [announcements, setAnnouncements] = useState([]);
const [tempPublishedList, setTempPublishedList] = useState([])
const [announcementData, setAnnouncementData] = useState({});
const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
const { show } = React.useContext(MyContext);
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const hasInitialized = useRef(false);
const hasInitializedWebsocket = useRef(false);
const editorRef = useRef(null);
const setEditorRef = (editorInstance) => {
editorRef.current = editorInstance;
};
const getAnnouncementData = async ({ identifier, name }) => {
try {
const res = await requestQueuePublishedAccouncements.enqueue(()=> {
return fetch(
`${getBaseApiReact()}/arbitrary/DOCUMENT/${name}/${identifier}?encoding=base64`
);
})
const data = await res.text();
const response = await decryptPublishes([{ data }], secretKey);
const messageData = response[0];
setAnnouncementData((prev) => {
return {
...prev,
[`${identifier}-${name}`]: messageData,
};
});
} catch (error) {}
};
useEffect(() => {
if (!secretKey || hasInitializedWebsocket.current) return;
setIsLoading(true);
// initWebsocketMessageGroup()
hasInitializedWebsocket.current = true;
}, [secretKey]);
const encryptChatMessage = async (data: string, secretKeyObject: any) => {
try {
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "encryptSingle",
payload: {
data,
secretKeyObject,
},
},
(response) => {
if (!response?.error) {
res(response);
return;
}
rej(response.error);
}
);
});
} catch (error) {}
};
const publishAnc = async ({ encryptedData, identifier }: any) => {
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "publishGroupEncryptedResource",
payload: {
encryptedData,
identifier,
},
},
(response) => {
if (!response?.error) {
res(response);
}
rej(response.error);
}
);
});
};
const clearEditorContent = () => {
if (editorRef.current) {
editorRef.current.chain().focus().clearContent().run();
}
};
const setTempData = async ()=> {
try {
const getTempAnnouncements = await getTempPublish()
if(getTempAnnouncements?.announcement){
let tempData = []
Object.keys(getTempAnnouncements?.announcement || {}).map((key)=> {
const value = getTempAnnouncements?.announcement[key]
tempData.push(value.data)
})
setTempPublishedList(tempData)
}
} catch (error) {
}
}
const publishAnnouncement = async () => {
try {
const fee = await getFee('ARBITRARY')
await show({
message: "Would you like to perform a ARBITRARY transaction?" ,
publishFee: fee.fee + ' QORT'
})
if (isSending) return;
if (editorRef.current) {
const htmlContent = editorRef.current.getHTML();
if (!htmlContent?.trim() || htmlContent?.trim() === "<p></p>") return;
setIsSending(true);
const message = {
version: 1,
extra: {},
message: htmlContent
}
const secretKeyObject = await getSecretKey();
const message64: any = await objectToBase64(message);
const encryptSingle = await encryptChatMessage(
message64,
secretKeyObject
);
const randomUid = uid.rnd();
const identifier = `grp-${selectedGroup}-anc-${randomUid}`;
const res = await publishAnc({
encryptedData: encryptSingle,
identifier
});
const dataToSaveToStorage = {
name: myName,
identifier,
service: 'DOCUMENT',
tempData: message,
created: Date.now()
}
await saveTempPublish({data: dataToSaveToStorage, key: 'announcement'})
setTempData()
clearEditorContent();
}
// send chat message
} catch (error) {
setInfoSnack({
type: "error",
message: error,
});
setOpenSnack(true)
} finally {
setIsSending(false);
}
};
const getAnnouncements = React.useCallback(
async (selectedGroup) => {
try {
const offset = 0;
// dispatch(setIsLoadingGlobal(true))
const identifier = `grp-${selectedGroup}-anc-`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=20&includemetadata=false&offset=${offset}&reverse=true&prefix=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
setTempData()
setAnnouncements(responseData);
setIsLoading(false);
for (const data of responseData) {
getAnnouncementData({ name: data.name, identifier: data.identifier });
}
} catch (error) {
} finally {
// dispatch(setIsLoadingGlobal(false))
}
},
[secretKey]
);
React.useEffect(() => {
if (selectedGroup && secretKey && !hasInitialized.current) {
getAnnouncements(selectedGroup);
hasInitialized.current = true
}
}, [selectedGroup, secretKey]);
const loadMore = async()=> {
try {
setIsLoading(true);
const offset = announcements.length
const identifier = `grp-${selectedGroup}-anc-`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=20&includemetadata=false&offset=${offset}&reverse=true&prefix=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
setAnnouncements((prev)=> [...prev, ...responseData]);
setIsLoading(false);
for (const data of responseData) {
getAnnouncementData({ name: data.name, identifier: data.identifier });
}
} catch (error) {
}
}
const interval = useRef<any>(null)
const checkNewMessages = React.useCallback(
async () => {
try {
const identifier = `grp-${selectedGroup}-anc-`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=20&includemetadata=false&offset=${0}&reverse=true&prefix=true`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const latestMessage = announcements[0]
if (!latestMessage) {
for (const data of responseData) {
try {
getAnnouncementData({ name: data.name, identifier: data.identifier });
} catch (error) {}
}
setAnnouncements(responseData)
return
}
const findMessage = responseData?.findIndex(
(item: any) => item?.identifier === latestMessage?.identifier
)
if(findMessage === -1) return
const newArray = responseData.slice(0, findMessage)
for (const data of newArray) {
try {
getAnnouncementData({ name: data.name, identifier: data.identifier });
} catch (error) {}
}
setAnnouncements((prev)=> [...newArray, ...prev])
} catch (error) {
} finally {
}
},
[announcements, secretKey, selectedGroup]
)
const checkNewMessagesFunc = useCallback(() => {
let isCalling = false
interval.current = setInterval(async () => {
if (isCalling) return
isCalling = true
const res = await checkNewMessages()
isCalling = false
}, 20000)
}, [checkNewMessages])
useEffect(() => {
if(!secretKey) return
checkNewMessagesFunc()
return () => {
if (interval?.current) {
clearInterval(interval.current)
}
}
}, [checkNewMessagesFunc])
const combinedListTempAndReal = useMemo(() => {
// Combine the two lists
const combined = [...tempPublishedList, ...announcements];
// Remove duplicates based on the "identifier"
const uniqueItems = new Map();
combined.forEach(item => {
uniqueItems.set(item.identifier, item); // This will overwrite duplicates, keeping the last occurrence
});
// 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.created - a.created);
return sortedList;
}, [tempPublishedList, announcements]);
if(selectedAnnouncement){
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
width: "100%",
visibility: hide && 'hidden',
position: hide && 'fixed',
left: hide && '-1000px'
}}
>
<AnnouncementDiscussion myName={myName} show={show} secretKey={secretKey} selectedAnnouncement={selectedAnnouncement} setSelectedAnnouncement={setSelectedAnnouncement} encryptChatMessage={encryptChatMessage} getSecretKey={getSecretKey} />
</div>
)
}
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
width: "100%",
visibility: hide && 'hidden',
position: hide && 'fixed',
left: hide && '-1000px'
}}
>
<div style={{
position: "relative",
width: "100%",
display: "flex",
flexDirection: "column",
flexShrink: 0,
}}>
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
padding: "25px",
fontSize: "20px",
gap: '20px',
alignItems: 'center'
}}
>
<CampaignIcon sx={{
fontSize: '30px'
}} />
Group Announcements
</Box>
<Spacer height="25px" />
</div>
{!isLoading && combinedListTempAndReal?.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<Typography sx={{
fontSize: '16px'
}}>No announcements</Typography>
</Box>
)}
<AnnouncementList
announcementData={announcementData}
initialMessages={combinedListTempAndReal}
setSelectedAnnouncement={setSelectedAnnouncement}
disableComment={false}
showLoadMore={announcements.length > 0 && announcements.length % 20 === 0}
loadMore={loadMore}
/>
{isAdmin && (
<div
style={{
// position: 'fixed',
// bottom: '0px',
backgroundColor: "#232428",
minHeight: "150px",
maxHeight: "400px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
width: "100%",
boxSizing: "border-box",
padding: "20px",
flexShrink: 0
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
// height: '100%',
overflow: "auto",
}}
>
<Tiptap
setEditorRef={setEditorRef}
onEnter={publishAnnouncement}
disableEnter
/>
</div>
<CustomButton
onClick={() => {
if (isSending) return;
publishAnnouncement();
}}
style={{
marginTop: "auto",
alignSelf: "center",
cursor: isSending ? "default" : "pointer",
background: isSending && "rgba(0, 0, 0, 0.8)",
flexShrink: 0,
}}
>
{isSending && (
<CircularProgress
size={18}
sx={{
position: "absolute",
top: "50%",
left: "50%",
marginTop: "-12px",
marginLeft: "-12px",
color: "white",
}}
/>
)}
{` Publish Announcement`}
</CustomButton>
</div>
)}
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
<LoadingSnackbar
open={isLoading}
info={{
message: "Loading announcements... please wait.",
}}
/>
</div>
);
};

View File

@ -0,0 +1,52 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { GroupMail } from "../Group/Forum/GroupMail";
export const GroupForum = ({
selectedGroup,
userInfo,
secretKey,
getSecretKey,
isAdmin,
myAddress,
hide,
defaultThread,
setDefaultThread
}) => {
const [isMoved, setIsMoved] = useState(false);
useEffect(() => {
if (hide) {
setTimeout(() => setIsMoved(true), 300); // Wait for the fade-out to complete before moving
} else {
setIsMoved(false); // Reset the position immediately when showing
}
}, [hide]);
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
width: "100%",
opacity: hide ? 0 : 1,
visibility: hide && 'hidden',
position: hide ? 'fixed' : 'relative',
left: hide && '-1000px'
}}
>
<GroupMail getSecretKey={getSecretKey} selectedGroup={selectedGroup} userInfo={userInfo} secretKey={secretKey} defaultThread={defaultThread} setDefaultThread={setDefaultThread} />
</div>
);
};

View File

@ -0,0 +1,66 @@
import React, { useEffect } from 'react';
import DOMPurify from 'dompurify';
import './styles.css'; // Ensure this CSS file is imported
export const MessageDisplay = ({ htmlContent }) => {
const linkify = (text) => {
// Regular expression to find URLs starting with https://, http://, or www.
const urlPattern = /(\bhttps?:\/\/[^\s<]+|\bwww\.[^\s<]+)/g;
// Replace plain text URLs with anchor tags
return text.replace(urlPattern, (url) => {
const href = url.startsWith('http') ? url : `https://${url}`;
return `<a href="${href}" class="auto-link">${DOMPurify.sanitize(url)}</a>`;
});
};
// Sanitize and linkify the content
const sanitizedContent = DOMPurify.sanitize(linkify(htmlContent), {
ALLOWED_TAGS: [
'a', 'b', 'i', 'em', 'strong', 'p', 'br', 'div', 'span', 'img',
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
],
ALLOWED_ATTR: [
'href', 'target', 'rel', 'class', 'src', 'alt', 'title',
'width', 'height', 'style', 'align', 'valign', 'colspan', 'rowspan', 'border', 'cellpadding', 'cellspacing'
],
});
// Function to handle link clicks
const handleClick = (e) => {
e.preventDefault();
// Ensure we are targeting an <a> element
const target = e.target.closest('a');
if (target) {
const href = target.getAttribute('href');
if (chrome && chrome.tabs) {
chrome.tabs.create({ url: href }, (tab) => {
if (chrome.runtime.lastError) {
console.error('Error opening tab:', chrome.runtime.lastError);
} else {
console.log('Tab opened successfully:', tab);
}
});
} else {
console.error('chrome.tabs API is not available.');
}
} else {
console.error('No <a> tag found or href is null.');
}
};
return (
<div
className="tiptap"
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
onClick={(e) => {
// Delegate click handling to the parent div
if (e.target.tagName === 'A') {
handleClick(e);
}
}}
/>
);
};

View File

@ -0,0 +1,93 @@
import { Message } from "@chatscope/chat-ui-kit-react";
import React, { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import { MessageDisplay } from "./MessageDisplay";
import { Avatar, Box, Typography } from "@mui/material";
import { formatTimestamp } from "../../utils/time";
import { getBaseApi } from "../../background";
import { getBaseApiReact } from "../../App";
export const MessageItem = ({ message, onSeen }) => {
const { ref, inView } = useInView({
threshold: 1.0, // Fully visible
triggerOnce: true, // Only trigger once when it becomes visible
});
useEffect(() => {
if (inView && message.unread) {
onSeen(message.id);
}
}, [inView, message.id, message.unread, onSeen]);
return (
<div
ref={ref}
style={{
padding: "10px",
backgroundColor: "#232428",
borderRadius: "7px",
width: "95%",
display: "flex",
gap: '7px',
}}
>
<Avatar
sx={{
backgroundColor: '#27282c',
color: 'white'
}}
alt={message?.senderName}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${message?.senderName}/qortal_avatar?async=true`}
>
{message?.senderName?.charAt(0)}
</Avatar>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "7px",
width: '100%'
}}
>
<Typography
sx={{
fontWight: 600,
fontFamily: "Inter",
color: "cadetBlue",
}}
>
{message?.senderName || message?.sender}
</Typography>
{message?.text?.type === "notification" ? (
<MessageDisplay htmlContent={message.text?.data?.message} />
) : (
<MessageDisplay htmlContent={message.text} />
)}
<Box sx={{
display: 'flex',
justifyContent: 'flex-end',
width: '100%'
}}>
<Typography sx={{
fontSize: '14px',
color: 'gray',
fontFamily: 'Inter'
}}>{formatTimestamp(message.timestamp)}</Typography>
</Box>
</Box>
{/* <Message
model={{
direction: 'incoming',
message: message.text,
position: 'single',
sender: message.senderName,
sentTime: message.timestamp
}}
></Message> */}
{/* {!message.unread && <span style={{ color: 'green' }}> Seen</span>} */}
</div>
);
};

View File

@ -0,0 +1,63 @@
import React, { useRef } from 'react';
import { NodeViewWrapper } from '@tiptap/react';
const ResizableImage = ({ node, updateAttributes, selected }) => {
const imgRef = useRef(null);
const startResizing = (e) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startWidth = imgRef.current.offsetWidth;
const onMouseMove = (e) => {
const newWidth = startWidth + e.clientX - startX;
updateAttributes({ width: `${newWidth}px` });
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
return (
<NodeViewWrapper
as="div"
className={`resizable-image ${selected ? 'selected' : ''}`}
style={{
display: 'inline-block',
position: 'relative',
userSelect: 'none', // Prevent selection to avoid interference with the text cursor
}}
>
<img
ref={imgRef}
src={node.attrs.src}
alt={node.attrs.alt || ''}
title={node.attrs.title || ''}
style={{ width: node.attrs.width || 'auto', display: 'block', margin: '0 auto' }}
draggable={false} // Prevent image dragging
/>
<div
style={{
position: 'absolute',
right: 0,
bottom: 0,
width: '10px',
height: '10px',
backgroundColor: 'gray',
cursor: 'nwse-resize',
zIndex: 1, // Ensure the resize handle is above other content
}}
onMouseDown={startResizing}
></div>
</NodeViewWrapper>
);
};
export default ResizableImage;

View File

@ -0,0 +1,292 @@
import React, { useEffect, useRef } from 'react';
import { EditorProvider, useCurrentEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { Color } from '@tiptap/extension-color';
import ListItem from '@tiptap/extension-list-item';
import TextStyle from '@tiptap/extension-text-style';
import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image';
import IconButton from '@mui/material/IconButton';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import FormatItalicIcon from '@mui/icons-material/FormatItalic';
import StrikethroughSIcon from '@mui/icons-material/StrikethroughS';
import FormatClearIcon from '@mui/icons-material/FormatClear';
import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
import CodeIcon from '@mui/icons-material/Code';
import ImageIcon from '@mui/icons-material/Image'; // Import Image icon
import FormatQuoteIcon from '@mui/icons-material/FormatQuote';
import HorizontalRuleIcon from '@mui/icons-material/HorizontalRule';
import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
import FormatHeadingIcon from '@mui/icons-material/FormatSize';
import DeveloperModeIcon from '@mui/icons-material/DeveloperMode';
import CustomImage from './CustomImage';
import Compressor from 'compressorjs'
import ImageResize from 'tiptap-extension-resize-image'; // Import the ResizeImage extension
const MenuBar = ({ setEditorRef }) => {
const { editor } = useCurrentEditor();
const fileInputRef = useRef(null);
if (!editor) {
return null;
}
useEffect(() => {
if (editor && setEditorRef) {
setEditorRef(editor);
}
}, [editor, setEditorRef]);
const handleImageUpload = async (event) => {
const file = event.target.files[0];
let compressedFile
await new Promise<void>((resolve) => {
new Compressor(file, {
quality: 0.6,
maxWidth: 1200,
mimeType: 'image/webp',
success(result) {
const file = new File([result], 'name', {
type: 'image/webp'
})
compressedFile = file
resolve()
},
error(err) {}
})
})
if (compressedFile) {
const reader = new FileReader();
reader.onload = () => {
const url = reader.result;
editor.chain().focus().setImage({ src: url , style: "width: auto"}).run();
fileInputRef.current.value = '';
};
reader.readAsDataURL(compressedFile);
}
};
const triggerImageUpload = () => {
fileInputRef.current.click(); // Trigger the file input click
};
return (
<div className="control-group">
<div className="button-group">
<IconButton
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={
!editor.can()
.chain()
.focus()
.toggleBold()
.run()
}
// color={editor.isActive('bold') ? 'white' : 'gray'}
sx={{
color: editor.isActive('bold') ? 'white' : 'gray'
}}
>
<FormatBoldIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={
!editor.can()
.chain()
.focus()
.toggleItalic()
.run()
}
// color={editor.isActive('italic') ? 'white' : 'gray'}
sx={{
color: editor.isActive('italic') ? 'white' : 'gray'
}}
>
<FormatItalicIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={
!editor.can()
.chain()
.focus()
.toggleStrike()
.run()
}
// color={editor.isActive('strike') ? 'white' : 'gray'}
sx={{
color: editor.isActive('strike') ? 'white' : 'gray'
}}
>
<StrikethroughSIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={
!editor.can()
.chain()
.focus()
.toggleCode()
.run()
}
// color={editor.isActive('code') ? 'white' : 'gray'}
sx={{
color: editor.isActive('code') ? 'white' : 'gray'
}}
>
<CodeIcon />
</IconButton>
<IconButton onClick={() => editor.chain().focus().unsetAllMarks().run()}>
<FormatClearIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
// color={editor.isActive('bulletList') ? 'white' : 'gray'}
sx={{
color: editor.isActive('bulletList') ? 'white' : 'gray'
}}
>
<FormatListBulletedIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
// color={editor.isActive('orderedList') ? 'white' : 'gray'}
sx={{
color: editor.isActive('orderedList') ? 'white' : 'gray'
}}
>
<FormatListNumberedIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
// color={editor.isActive('codeBlock') ? 'white' : 'gray'}
sx={{
color: editor.isActive('codeBlock') ? 'white' : 'gray'
}}
>
<DeveloperModeIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
// color={editor.isActive('blockquote') ? 'white' : 'gray'}
sx={{
color: editor.isActive('blockquote') ? 'white' : 'gray'
}}
>
<FormatQuoteIcon />
</IconButton>
<IconButton onClick={() => editor.chain().focus().setHorizontalRule().run()}>
<HorizontalRuleIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
// color={editor.isActive('heading', { level: 1 }) ? 'white' : 'gray'}
sx={{
color: editor.isActive('heading', { level: 1 }) ? 'white' : 'gray'
}}
>
<FormatHeadingIcon fontSize="small" />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().undo().run()}
disabled={
!editor.can()
.chain()
.focus()
.undo()
.run()
}
sx={{
color: 'gray'
}}
>
<UndoIcon />
</IconButton>
<IconButton
sx={{
color: 'gray'
}}
onClick={() => editor.chain().focus().redo().run()}
disabled={
!editor.can()
.chain()
.focus()
.redo()
.run()
}
>
<RedoIcon />
</IconButton>
<IconButton
onClick={triggerImageUpload}
sx={{
color: 'gray'
}}
>
<ImageIcon />
</IconButton>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleImageUpload}
accept="image/*" // Limit file types to images only
/>
</div>
</div>
);
};
const extensions = [
Color.configure({ types: [TextStyle.name, ListItem.name] }),
TextStyle.configure({ types: [ListItem.name] }),
StarterKit.configure({
bulletList: {
keepMarks: true,
keepAttributes: false,
},
orderedList: {
keepMarks: true,
keepAttributes: false,
},
}),
Placeholder.configure({
placeholder: 'Start typing here...', // Add your placeholder text here
}),
ImageResize,
];
const content = ``;
export default ({ setEditorRef, onEnter, disableEnter }) => {
return (
<EditorProvider
slotBefore={<MenuBar setEditorRef={setEditorRef} />}
extensions={extensions}
content={content}
editorProps={{
handleKeyDown(view, event) {
if (!disableEnter && event.key === 'Enter') {
if (event.shiftKey) {
// Shift+Enter: Insert a hard break
view.dispatch(view.state.tr.replaceSelectionWith(view.state.schema.nodes.hardBreak.create()));
return true;
} else {
// Enter: Call the callback function
if (typeof onEnter === 'function') {
onEnter();
}
return true; // Prevent the default action of adding a new line
}
}
return false; // Allow default handling for other keys
},
}}
/>
);
};

View File

@ -0,0 +1,121 @@
.tiptap {
margin-top: 0;
color: white; /* Set default font color to white */
width: 100%;
}
.tiptap ul,
.tiptap ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
}
.tiptap ul li p,
.tiptap ol li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
/* Heading styles */
.tiptap h1,
.tiptap h2,
.tiptap h3,
.tiptap h4,
.tiptap h5,
.tiptap h6 {
line-height: 1.1;
margin-top: 2.5rem;
text-wrap: pretty;
color: white; /* Ensure heading font color is white */
}
.tiptap h1,
.tiptap h2 {
margin-top: 3.5rem;
margin-bottom: 1.5rem;
}
.tiptap h1 {
font-size: 1.4rem;
}
.tiptap h2 {
font-size: 1.2rem;
}
.tiptap h3 {
font-size: 1.1rem;
}
.tiptap h4,
.tiptap h5,
.tiptap h6 {
font-size: 1rem;
}
/* Code and preformatted text styles */
.tiptap code {
background-color: #27282c; /* Set code background color to #27282c */
border-radius: 0.4rem;
color: white; /* Ensure inline code text color is white */
font-size: 0.85rem;
padding: 0.25em 0.3em;
text-wrap: pretty;
}
.tiptap pre {
background: #27282c; /* Set code block background color to #27282c */
border-radius: 0.5rem;
color: white; /* Ensure code block text color is white */
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
outline: none;
}
.tiptap pre code {
background: none;
color: inherit; /* Inherit text color from the parent pre block */
font-size: 0.8rem;
padding: 0;
text-wrap: pretty;
}
.tiptap blockquote {
border-left: 3px solid var(--gray-3);
margin: 1.5rem 0;
padding-left: 1rem;
color: white; /* Ensure blockquote text color is white */
text-wrap: pretty;
}
.tiptap hr {
border: none;
border-top: 1px solid var(--gray-2);
margin: 2rem 0;
}
.ProseMirror:focus-visible {
outline: none !important;
}
.tiptap p {
font-size: 16px;
color: white; /* Ensure paragraph text color is white */
}
.tiptap p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap a {
color: cadetblue
}
.tiptap img {
display: block;
max-width: 100%;
}

View File

@ -0,0 +1,474 @@
import * as React from "react";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import ListItemText from "@mui/material/ListItemText";
import ListItemButton from "@mui/material/ListItemButton";
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import CloseIcon from "@mui/icons-material/Close";
import ExpandLess from "@mui/icons-material/ExpandLess";
import ExpandMore from "@mui/icons-material/ExpandMore";
import Slide from "@mui/material/Slide";
import { TransitionProps } from "@mui/material/transitions";
import {
Box,
Collapse,
Input,
MenuItem,
Select,
SelectChangeEvent,
Tab,
Tabs,
styled,
} from "@mui/material";
import { AddGroupList } from "./AddGroupList";
import { UserListOfInvites } from "./UserListOfInvites";
import { CustomizedSnackbars } from "../Snackbar/Snackbar";
import { getFee } from "../../background";
import { MyContext } from "../../App";
import { subscribeToEvent, unsubscribeFromEvent } from "../../utils/events";
export const Label = styled("label")(
({ theme }) => `
font-family: 'IBM Plex Sans', sans-serif;
font-size: 14px;
display: block;
margin-bottom: 4px;
font-weight: 400;
`
);
const Transition = React.forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement;
},
ref: React.Ref<unknown>
) {
return <Slide direction="up" ref={ref} {...props} />;
});
export const AddGroup = ({ address, open, setOpen }) => {
const {show, setTxList} = React.useContext(MyContext)
const [tab, setTab] = React.useState("create");
const [openAdvance, setOpenAdvance] = React.useState(false);
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [groupType, setGroupType] = React.useState("1");
const [approvalThreshold, setApprovalThreshold] = React.useState("40");
const [minBlock, setMinBlock] = React.useState("5");
const [maxBlock, setMaxBlock] = React.useState("21600");
const [value, setValue] = React.useState(0);
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
const handleClose = () => {
setOpen(false);
};
const handleChangeGroupType = (event: SelectChangeEvent) => {
setGroupType(event.target.value as string);
};
const handleChangeApprovalThreshold = (event: SelectChangeEvent) => {
setGroupType(event.target.value as string);
};
const handleChangeMinBlock = (event: SelectChangeEvent) => {
setMinBlock(event.target.value as string);
};
const handleChangeMaxBlock = (event: SelectChangeEvent) => {
setMaxBlock(event.target.value as string);
};
const handleCreateGroup = async () => {
try {
if(!name) throw new Error('Please provide a name')
if(!description) throw new Error('Please provide a description')
const fee = await getFee('CREATE_GROUP')
await show({
message: "Would you like to perform an CREATE_GROUP transaction?" ,
publishFee: fee.fee + ' QORT'
})
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "createGroup",
payload: {
groupName: name,
groupDescription: description,
groupType: +groupType,
groupApprovalThreshold: +approvalThreshold,
minBlock: +minBlock,
maxBlock: +maxBlock,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message: "Successfully created group. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
setTxList((prev)=> [{
...response,
type: 'created-group',
label: `Created group ${name}: awaiting confirmation`,
labelDone: `Created group ${name}: success !`,
done: false
}, ...prev])
res(response);
return
}
rej({message: response.error});
}
);
});
} catch (error) {
setInfoSnack({
type: "error",
message: error?.message,
});
setOpenSnack(true);
}
};
function CustomTabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
</div>
);
}
function a11yProps(index: number) {
return {
id: `simple-tab-${index}`,
"aria-controls": `simple-tabpanel-${index}`,
};
}
const openGroupInvitesRequestFunc = ()=> {
setValue(2)
}
React.useEffect(() => {
subscribeToEvent("openGroupInvitesRequest", openGroupInvitesRequestFunc);
return () => {
unsubscribeFromEvent("openGroupInvitesRequest", openGroupInvitesRequestFunc);
};
}, []);
return (
<React.Fragment>
<Dialog
fullScreen
open={open}
onClose={handleClose}
TransitionComponent={Transition}
>
<AppBar sx={{ position: "relative", bgcolor: "#232428" }}>
<Toolbar>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
Add Group
</Typography>
<IconButton
edge="start"
color="inherit"
onClick={handleClose}
aria-label="close"
>
<CloseIcon />
</IconButton>
{/* <Button autoFocus color="inherit" onClick={handleClose}>
save
</Button> */}
</Toolbar>
</AppBar>
<Box
sx={{
bgcolor: "#27282c",
flexGrow: 1,
overflowY: "auto",
color: "white",
}}
>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs
sx={{
"& .MuiTabs-indicator": {
backgroundColor: "white",
},
}}
value={value}
onChange={handleChange}
aria-label="basic tabs example"
>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="Create Group"
{...a11yProps(0)}
/>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="Find Group"
{...a11yProps(1)}
/>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="Group Invites"
{...a11yProps(2)}
/>
</Tabs>
</Box>
{value === 0 && (
<Box sx={{
width: '100%',
padding: '25px'
}}>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "20px",
maxWidth: "500px",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "5px",
}}
>
<Label>Name of group</Label>
<Input
placeholder="Name of group"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "5px",
}}
>
<Label>Description of group</Label>
<Input
placeholder="Description of group"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "5px",
}}
>
<Label>Group type</Label>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={groupType}
label="Group Type"
onChange={handleChangeGroupType}
>
<MenuItem value={1}>Open (public)</MenuItem>
<MenuItem value={0}>
Closed (private) - users need permission to join
</MenuItem>
</Select>
</Box>
<Box
sx={{
display: "flex",
gap: "15px",
alignItems: "center",
cursor: "pointer",
}}
onClick={() => setOpenAdvance((prev) => !prev)}
>
<Typography>Advanced options</Typography>
{openAdvance ? <ExpandLess /> : <ExpandMore />}
</Box>
<Collapse in={openAdvance} timeout="auto" unmountOnExit>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "5px",
}}
>
<Label>
Group Approval Threshold (number / percentage of Admins that
must approve a transaction)
</Label>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={approvalThreshold}
label="Group Approval Threshold"
onChange={handleChangeApprovalThreshold}
>
<MenuItem value={0}>NONE</MenuItem>
<MenuItem value={1}>ONE </MenuItem>
<MenuItem value={20}>20% </MenuItem>
<MenuItem value={40}>40% </MenuItem>
<MenuItem value={60}>60% </MenuItem>
<MenuItem value={80}>80% </MenuItem>
<MenuItem value={100}>100% </MenuItem>
</Select>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "5px",
}}
>
<Label>
Minimum Block delay for Group Transaction Approvals
</Label>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={minBlock}
label="Minimum Block delay"
onChange={handleChangeMinBlock}
>
<MenuItem value={5}>5 minutes</MenuItem>
<MenuItem value={10}>10 minutes</MenuItem>
<MenuItem value={30}>30 minutes</MenuItem>
<MenuItem value={60}>1 hour</MenuItem>
<MenuItem value={180}>3 hours</MenuItem>
<MenuItem value={300}>5 hours</MenuItem>
<MenuItem value={420}>7 hours</MenuItem>
<MenuItem value={720}>12 hours</MenuItem>
<MenuItem value={1440}>1 day</MenuItem>
<MenuItem value={4320}>3 days</MenuItem>
<MenuItem value={7200}>5 days</MenuItem>
<MenuItem value={10080}>7 days</MenuItem>
</Select>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "5px",
}}
>
<Label>
Maximum Block delay for Group Transaction Approvals
</Label>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={maxBlock}
label="Maximum Block delay"
onChange={handleChangeMaxBlock}
>
<MenuItem value={60}>1 hour</MenuItem>
<MenuItem value={180}>3 hours</MenuItem>
<MenuItem value={300}>5 hours</MenuItem>
<MenuItem value={420}>7 hours</MenuItem>
<MenuItem value={720}>12 hours</MenuItem>
<MenuItem value={1440}>1 day</MenuItem>
<MenuItem value={4320}>3 days</MenuItem>
<MenuItem value={7200}>5 days</MenuItem>
<MenuItem value={10080}>7 days</MenuItem>
<MenuItem value={14400}>10 days</MenuItem>
<MenuItem value={21600}>15 days</MenuItem>
</Select>
</Box>
</Collapse>
<Box
sx={{
display: "flex",
width: "100%",
justifyContent: "center",
}}
>
<Button
variant="contained"
color="primary"
onClick={handleCreateGroup}
>
Create Group
</Button>
</Box>
</Box>
</Box>
)}
{value === 1 && (
<Box sx={{
width: '100%',
padding: '25px'
}}>
<AddGroupList setOpenSnack={setOpenSnack} setInfoSnack={setInfoSnack} />
</Box>
)}
{value === 2 && (
<Box sx={{
width: '100%',
padding: '25px'
}}>
<UserListOfInvites myAddress={address} setOpenSnack={setOpenSnack} setInfoSnack={setInfoSnack} />
</Box>
)}
</Box>
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
</Dialog>
</React.Fragment>
);
};

View File

@ -0,0 +1,272 @@
import {
Box,
Button,
ListItem,
ListItemButton,
ListItemText,
Popover,
TextField,
Typography,
} from "@mui/material";
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
AutoSizer,
CellMeasurer,
CellMeasurerCache,
List,
} from "react-virtualized";
import _ from "lodash";
import { MyContext, getBaseApiReact } from "../../App";
import { LoadingButton } from "@mui/lab";
import { getBaseApi, getFee } from "../../background";
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const AddGroupList = ({ setInfoSnack, setOpenSnack }) => {
const { memberGroups, show, setTxList } = useContext(MyContext);
const [groups, setGroups] = useState([]);
const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to
const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Track which list item has the popover open
const listRef = useRef();
const [inputValue, setInputValue] = useState("");
const [filteredItems, setFilteredItems] = useState(groups);
const [isLoading, setIsLoading] = useState(false);
const handleFilter = useCallback(
(query) => {
if (query) {
setFilteredItems(
groups.filter((item) =>
item.groupName.toLowerCase().includes(query.toLowerCase())
)
);
} else {
setFilteredItems(groups);
}
},
[groups]
);
const debouncedFilter = useMemo(
() => _.debounce(handleFilter, 500),
[handleFilter]
);
const handleChange = (event) => {
const value = event.target.value;
setInputValue(value);
debouncedFilter(value);
};
const getGroups = async () => {
try {
const response = await fetch(
`${getBaseApiReact()}/groups/?limit=0`
);
const groupData = await response.json();
const filteredGroup = groupData.filter(
(item) => !memberGroups.find((group) => group.groupId === item.groupId)
);
setGroups(filteredGroup);
setFilteredItems(filteredGroup);
} catch (error) {
console.error(error);
}
};
useEffect(() => {
getGroups();
}, [memberGroups]);
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const handleJoinGroup = async (group, isOpen) => {
try {
const groupId = group.groupId;
const fee = await getFee('JOIN_GROUP')
await show({
message: "Would you like to perform an JOIN_GROUP transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoading(true);
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "joinGroup",
payload: {
groupId,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message: "Successfully requested to join group. It may take a couple of minutes for the changes to propagate",
});
if(isOpen){
setTxList((prev)=> [{
...response,
type: 'joined-group',
label: `Joined Group ${group?.groupName}: awaiting confirmation`,
labelDone: `Joined Group ${group?.groupName}: success !`,
done: false,
groupId,
}, ...prev])
} else {
setTxList((prev)=> [{
...response,
type: 'joined-group-request',
label: `Requested to join Group ${group?.groupName}: awaiting confirmation`,
labelDone: `Requested to join Group ${group?.groupName}: success !`,
done: false,
groupId,
}, ...prev])
}
setOpenSnack(true);
handlePopoverClose();
res(response);
return;
} else {
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
}
);
});
setIsLoading(false);
} catch (error) {} finally {
setIsLoading(false);
}
};
const rowRenderer = ({ index, key, parent, style }) => {
const group = filteredItems[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style} onLoad={measure}>
<ListItem disablePadding>
<Popover
open={openPopoverIndex === index}
anchorEl={popoverAnchor}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
style={{ marginTop: "8px" }}
>
<Box
sx={{
width: "325px",
height: "250px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
padding: "10px",
}}
>
<Typography>Join {group?.groupName}</Typography>
<Typography>
{group?.isOpen === false &&
"This is a closed/private group, so you will need to wait until an admin accepts your request"}
</Typography>
<LoadingButton
loading={isLoading}
loadingPosition="start"
variant="contained"
onClick={() => handleJoinGroup(group, group?.isOpen)}
>
Join group
</LoadingButton>
</Box>
</Popover>
<ListItemButton
onClick={(event) => handlePopoverOpen(event, index)}
>
<ListItemText
primary={group?.groupName}
secondary={group?.description}
/>
</ListItemButton>
</ListItem>
</div>
)}
</CellMeasurer>
);
};
return (
<div>
<p>Groups list</p>
<TextField
label="Search for Groups"
variant="outlined"
fullWidth
value={inputValue}
onChange={handleChange}
/>
<div
style={{
position: "relative",
height: "500px",
width: "600px",
display: "flex",
flexDirection: "column",
flexShrink: 1,
}}
>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={filteredItems.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</div>
);
};

View File

@ -0,0 +1,45 @@
import { useMemo } from "react";
import DOMPurify from "dompurify";
import "react-quill/dist/quill.snow.css";
import "react-quill/dist/quill.core.css";
import "react-quill/dist/quill.bubble.css";
import { Box, styled } from "@mui/material";
import { convertQortalLinks } from "../../../utils/qortalLink";
const CrowdfundInlineContent = styled(Box)(({ theme }) => ({
display: "flex",
fontFamily: "Mulish",
fontSize: "19px",
fontWeight: 400,
letterSpacing: 0,
color: theme.palette.text.primary,
width: '100%'
}));
export const DisplayHtml = ({ html, textColor }: any) => {
const cleanContent = useMemo(() => {
if (!html) return null;
const sanitize: string = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
const anchorQortal = convertQortalLinks(sanitize);
return anchorQortal;
}, [html]);
if (!cleanContent) return null;
return (
<CrowdfundInlineContent>
<div
className="ql-editor-display"
style={{
color: textColor || 'white',
fontWeight: 400,
fontSize: '16px'
}}
dangerouslySetInnerHTML={{ __html: cleanContent }}
/>
</CrowdfundInlineContent>
);
};

View File

@ -0,0 +1,777 @@
import React, {
FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Avatar, Box, Popover, Typography } from "@mui/material";
// import { MAIL_SERVICE_TYPE, THREAD_SERVICE_TYPE } from "../../constants/mail";
import { Thread } from "./Thread";
import {
AllThreadP,
ArrowDownIcon,
ComposeContainer,
ComposeContainerBlank,
ComposeIcon,
ComposeP,
GroupContainer,
GroupNameP,
InstanceFooter,
InstanceListContainer,
InstanceListContainerRow,
InstanceListContainerRowCheck,
InstanceListContainerRowCheckIcon,
InstanceListContainerRowMain,
InstanceListContainerRowMainP,
InstanceListHeader,
InstanceListParent,
SelectInstanceContainerFilterInner,
SingleThreadParent,
ThreadContainer,
ThreadContainerFullWidth,
ThreadInfoColumn,
ThreadInfoColumnNameP,
ThreadInfoColumnTime,
ThreadInfoColumnbyP,
ThreadSingleLastMessageP,
ThreadSingleLastMessageSpanP,
ThreadSingleTitle,
} from "./Mail-styles";
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import { Spacer } from "../../../common/Spacer";
import { formatDate, formatTimestamp } from "../../../utils/time";
import LazyLoad from "../../../common/LazyLoad";
import { delay } from "../../../utils/helpers";
import { NewThread } from "./NewThread";
import { getBaseApi } from "../../../background";
import { decryptPublishes, getTempPublish } from "../../Chat/GroupAnnouncements";
import CheckSVG from "../../../assets/svgs/Check.svg";
import SortSVG from "../../../assets/svgs/Sort.svg";
import ArrowDownSVG from "../../../assets/svgs/ArrowDown.svg";
import { LoadingSnackbar } from "../../Snackbar/LoadingSnackbar";
import { executeEvent, subscribeToEvent, unsubscribeFromEvent } from "../../../utils/events";
import RefreshIcon from '@mui/icons-material/Refresh';
import { getBaseApiReact } from "../../../App";
const filterOptions = ["Recently active", "Newest", "Oldest"];
export const threadIdentifier = "DOCUMENT";
export const GroupMail = ({
selectedGroup,
userInfo,
getSecretKey,
secretKey,
defaultThread,
setDefaultThread
}) => {
const [viewedThreads, setViewedThreads] = React.useState<any>({});
const [filterMode, setFilterMode] = useState<string>("Recently active");
const [currentThread, setCurrentThread] = React.useState(null);
const [recentThreads, setRecentThreads] = useState<any[]>([]);
const [allThreads, setAllThreads] = useState<any[]>([]);
const [members, setMembers] = useState<any>(null);
const [isOpenFilterList, setIsOpenFilterList] = useState<boolean>(false);
const anchorElInstanceFilter = useRef<any>(null);
const [tempPublishedList, setTempPublishedList] = useState([])
const [isLoading, setIsLoading] = useState(false)
const groupIdRef = useRef<any>(null);
const groupId = useMemo(() => {
return selectedGroup?.groupId;
}, [selectedGroup]);
useEffect(() => {
if (groupId !== groupIdRef?.current) {
setCurrentThread(null);
setRecentThreads([]);
setAllThreads([]);
groupIdRef.current = groupId;
}
}, [groupId]);
const setTempData = async ()=> {
try {
const getTempAnnouncements = await getTempPublish()
if(getTempAnnouncements?.thread){
let tempData = []
Object.keys(getTempAnnouncements?.thread || {}).map((key)=> {
const value = getTempAnnouncements?.thread[key]
tempData.push(value.data)
})
setTempPublishedList(tempData)
}
} catch (error) {
}
}
const getEncryptedResource = async ({ name, identifier }) => {
const res = await fetch(
`${getBaseApiReact()}/arbitrary/DOCUMENT/${name}/${identifier}?encoding=base64`
);
const data = await res.text();
const response = await decryptPublishes([{ data }], secretKey);
const messageData = response[0];
return messageData.decryptedData;
};
const updateThreadActivity = async ({threadId, qortalName, groupId, thread}) => {
try {
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "updateThreadActivity",
payload: {
threadId, qortalName, groupId, thread
},
},
(response) => {
if (!response?.error) {
res(response);
return
}
rej(response.error);
}
);
});
} catch (error) {
} finally {
}
};
const getAllThreads = React.useCallback(
async (groupId: string, mode: string, isInitial?: boolean) => {
try {
setIsLoading(true)
const offset = isInitial ? 0 : allThreads.length;
const isReverse = mode === "Newest" ? true : false;
if (isInitial) {
// dispatch(setIsLoadingCustom("Loading threads"));
}
const identifier = `grp-${groupId}-thread-`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=${20}&includemetadata=false&offset=${offset}&reverse=${isReverse}&prefix=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
let fullArrayMsg = isInitial ? [] : [...allThreads];
const getMessageForThreads = responseData.map(async (message: any) => {
let fullObject: any = null;
if (message?.metadata?.description) {
fullObject = {
...message,
threadData: {
title: message?.metadata?.description,
groupId: groupId,
createdAt: message?.created,
name: message?.name,
},
threadOwner: message?.name,
};
} else {
let threadRes = null;
try {
threadRes = await Promise.race([
getEncryptedResource({
name: message.name,
identifier: message.identifier,
}),
delay(5000),
]);
} catch (error) {}
if (threadRes?.title) {
fullObject = {
...message,
threadData: threadRes,
threadOwner: message?.name,
threadId: message.identifier
};
}
}
if (fullObject?.identifier) {
const index = fullArrayMsg.findIndex(
(p) => p.identifier === fullObject.identifier
);
if (index !== -1) {
fullArrayMsg[index] = fullObject;
} else {
fullArrayMsg.push(fullObject);
}
}
});
await Promise.all(getMessageForThreads);
let sorted = fullArrayMsg;
if (isReverse) {
sorted = fullArrayMsg.sort((a: any, b: any) => b.created - a.created);
} else {
sorted = fullArrayMsg.sort((a: any, b: any) => a.created - b.created);
}
setAllThreads(sorted);
} catch (error) {
console.log({ error });
} finally {
if (isInitial) {
setIsLoading(false)
// dispatch(setIsLoadingCustom(null));
}
}
},
[allThreads]
);
const getMailMessages = React.useCallback(
async (groupId: string, members: any) => {
try {
setIsLoading(true)
// const memberNames = Object.keys(members);
// const queryString = memberNames
// .map(name => `&name=${encodeURIComponent(name)}`)
// .join("");
// dispatch(setIsLoadingCustom("Loading recent threads"));
const identifier = `thmsg-grp-${groupId}-thread-`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=100&includemetadata=false&offset=${0}&reverse=true&prefix=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
const messagesForThread: any = {};
for (const message of responseData) {
let str = message.identifier;
const parts = str.split("-");
// Get the second last element
const secondLastId = parts[parts.length - 2];
const result = `grp-${groupId}-thread-${secondLastId}`;
const checkMessage = messagesForThread[result];
if (!checkMessage) {
messagesForThread[result] = message;
}
}
const newArray = Object.keys(messagesForThread)
.map((key) => {
return {
...messagesForThread[key],
threadId: key,
};
})
.sort((a, b) => b.created - a.created)
.slice(0, 10);
let fullThreadArray: any = [];
const getMessageForThreads = newArray.map(async (message: any) => {
try {
const identifierQuery = message.threadId;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifierQuery}&limit=1&includemetadata=false&offset=${0}&reverse=true&prefix=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
if (responseData.length > 0) {
const thread = responseData[0];
if (thread?.metadata?.description) {
const fullObject = {
...message,
threadData: {
title: thread?.metadata?.description,
groupId: groupId,
createdAt: thread?.created,
name: thread?.name,
},
threadOwner: thread?.name,
};
fullThreadArray.push(fullObject);
} else {
let threadRes = await Promise.race([
getEncryptedResource({
name: thread.name,
identifier: message.threadId,
}),
delay(10000),
]);
if (threadRes?.title) {
const fullObject = {
...message,
threadData: threadRes,
threadOwner: thread?.name,
};
fullThreadArray.push(fullObject);
}
}
}
} catch (error) {
console.log(error);
}
return null;
});
await Promise.all(getMessageForThreads);
const sorted = fullThreadArray.sort(
(a: any, b: any) => b.created - a.created
);
setRecentThreads(sorted);
} catch (error) {
} finally {
setIsLoading(false)
// dispatch(setIsLoadingCustom(null));
}
},
[secretKey]
);
const getMessages = React.useCallback(async () => {
// if ( !groupId || members?.length === 0) return;
if (!groupId) return;
await getMailMessages(groupId, members);
}, [getMailMessages, groupId, members, secretKey]);
const interval = useRef<any>(null);
const firstMount = useRef(false);
const filterModeRef = useRef("");
useEffect(() => {
if (filterModeRef.current !== filterMode) {
firstMount.current = false;
}
// if (groupId && !firstMount.current && members.length > 0) {
if (groupId && !firstMount.current) {
if (filterMode === "Recently active") {
getMessages();
} else if (filterMode === "Newest") {
getAllThreads(groupId, "Newest", true);
} else if (filterMode === "Oldest") {
getAllThreads(groupId, "Oldest", true);
}
setTempData()
firstMount.current = true;
}
}, [groupId, members, filterMode]);
const closeThread = useCallback(() => {
setCurrentThread(null);
}, []);
const getGroupMembers = useCallback(async (groupNumber: string) => {
try {
const response = await fetch(`/groups/members/${groupNumber}?limit=0`);
const groupData = await response.json();
let members: any = {};
if (groupData && Array.isArray(groupData?.members)) {
for (const member of groupData.members) {
if (member.member) {
// const res = await getNameInfo(member.member);
// const resAddress = await qortalRequest({
// action: "GET_ACCOUNT_DATA",
// address: member.member,
// });
const name = res;
const publicKey = resAddress.publicKey;
if (name) {
members[name] = {
publicKey,
address: member.member,
};
}
}
}
}
setMembers(members);
} catch (error) {
console.log({ error });
}
}, []);
// useEffect(() => {
// if(groupId){
// getGroupMembers(groupId);
// interval.current = setInterval(async () => {
// getGroupMembers(groupId);
// }, 180000)
// }
// return () => {
// if (interval?.current) {
// clearInterval(interval.current)
// }
// }
// }, [getGroupMembers, groupId]);
let listOfThreadsToDisplay = recentThreads;
if (filterMode === "Newest" || filterMode === "Oldest") {
listOfThreadsToDisplay = allThreads;
}
const onSubmitNewThread = useCallback(
(val: any) => {
if (filterMode === "Recently active") {
setRecentThreads((prev) => [val, ...prev]);
} else if (filterMode === "Newest") {
setAllThreads((prev) => [val, ...prev]);
}
},
[filterMode]
);
// useEffect(()=> {
// if(user?.name){
// const threads = JSON.parse(
// localStorage.getItem(`qmail_threads_viewedtimestamp_${user.name}`) || "{}"
// );
// setViewedThreads(threads)
// }
// }, [user?.name, currentThread])
const handleCloseThreadFilterList = () => {
setIsOpenFilterList(false);
};
const refetchThreadsLists = useCallback(()=> {
if (filterMode === "Recently active") {
getMessages();
} else if (filterMode === "Newest") {
getAllThreads(groupId, "Newest", true);
} else if (filterMode === "Oldest") {
getAllThreads(groupId, "Oldest", true);
}
}, [filterMode])
const updateThreadActivityCurrentThread = ()=> {
if(!currentThread) return
const thread = currentThread
updateThreadActivity({
threadId: thread?.threadId, qortalName: thread?.threadData?.name, groupId: groupId, thread: thread
})
}
const setThreadFunc = (data)=> {
const thread = data
setCurrentThread(thread);
if(thread?.threadId && thread?.threadData?.name){
updateThreadActivity({
threadId: thread?.threadId, qortalName: thread?.threadData?.name, groupId: groupId, thread: thread
})
}
setTimeout(() => {
executeEvent("threadFetchMode", {
mode: "last-page"
});
}, 300);
}
useEffect(()=> {
if(defaultThread){
setThreadFunc(defaultThread)
setDefaultThread(null)
}
}, [defaultThread])
const combinedListTempAndReal = useMemo(() => {
// Combine the two lists
const transformTempPublishedList = tempPublishedList.map((item)=> {
return {
...item,
threadData: item.tempData,
threadOwner: item?.name,
threadId: item.identifier
}
})
const combined = [...transformTempPublishedList, ...listOfThreadsToDisplay];
// Remove duplicates based on the "identifier"
const uniqueItems = new Map();
combined.forEach(item => {
uniqueItems.set(item.threadId, item); // This will overwrite duplicates, keeping the last occurrence
});
// 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);
return sortedList;
}, [tempPublishedList, listOfThreadsToDisplay]);
if (currentThread)
return (
<Thread
currentThread={currentThread}
groupInfo={selectedGroup}
closeThread={closeThread}
members={members}
userInfo={userInfo}
secretKey={secretKey}
getSecretKey={getSecretKey}
updateThreadActivityCurrentThread={updateThreadActivityCurrentThread}
/>
);
return (
<GroupContainer
sx={{
position: "relative",
overflow: "auto",
width: "100%",
}}
>
<Popover
open={isOpenFilterList}
anchorEl={anchorElInstanceFilter.current}
onClose={handleCloseThreadFilterList}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<InstanceListParent
sx={{
minHeight: "unset",
width: "auto",
padding: "0px",
}}
>
<InstanceListHeader></InstanceListHeader>
<InstanceListContainer>
{filterOptions?.map((filter) => {
return (
<InstanceListContainerRow
onClick={() => {
setFilterMode(filter);
}}
sx={{
backgroundColor:
filterMode === filter ? "rgba(74, 158, 244, 1)" : "unset",
}}
key={filter}
>
<InstanceListContainerRowCheck>
{filter === filterMode && (
<InstanceListContainerRowCheckIcon src={CheckSVG} />
)}
</InstanceListContainerRowCheck>
<InstanceListContainerRowMain>
<InstanceListContainerRowMainP>
{filter}
</InstanceListContainerRowMainP>
</InstanceListContainerRowMain>
</InstanceListContainerRow>
);
})}
</InstanceListContainer>
<InstanceFooter></InstanceFooter>
</InstanceListParent>
</Popover>
<ThreadContainerFullWidth>
<ThreadContainer>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<NewThread
groupInfo={selectedGroup}
refreshLatestThreads={getMessages}
members={members}
publishCallback={setTempData}
userInfo={userInfo}
getSecretKey={getSecretKey}
myName={userInfo?.name}
/>
<ComposeContainerBlank
sx={{
height: "auto",
}}
>
{selectedGroup && !currentThread && (
<ComposeContainer
onClick={() => {
setIsOpenFilterList(true);
}}
ref={anchorElInstanceFilter}
>
<ComposeIcon src={SortSVG} />
<SelectInstanceContainerFilterInner>
<ComposeP>Sort by</ComposeP>
<ArrowDownIcon src={ArrowDownSVG} />
</SelectInstanceContainerFilterInner>
</ComposeContainer>
)}
</ComposeContainerBlank>
</Box>
<Spacer height="30px" />
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<AllThreadP>{filterMode}</AllThreadP>
<RefreshIcon onClick={refetchThreadsLists} sx={{
color: 'white',
cursor: 'pointer'
}} />
</Box>
<Spacer height="30px" />
{combinedListTempAndReal.map((thread) => {
const hasViewedRecent =
viewedThreads[
`qmail_threads_${thread?.threadData?.groupId}_${thread?.threadId}`
];
const shouldAppearLighter =
hasViewedRecent &&
filterMode === "Recently active" &&
thread?.threadData?.createdAt < hasViewedRecent?.timestamp;
return (
<SingleThreadParent
onClick={() => {
setCurrentThread(thread);
if(thread?.threadId && thread?.threadData?.name){
updateThreadActivity({
threadId: thread?.threadId, qortalName: thread?.threadData?.name, groupId: groupId, thread: thread
})
}
}}
>
<Avatar
sx={{
height: "50px",
width: "50px",
}}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${thread?.threadData?.name}/qortal_avatar?async=true`}
alt={thread?.threadData?.name}
>
{thread?.threadData?.name?.charAt(0)}
</Avatar>
<ThreadInfoColumn>
<ThreadInfoColumnNameP>
<ThreadInfoColumnbyP>by </ThreadInfoColumnbyP>
{thread?.threadData?.name}
</ThreadInfoColumnNameP>
<ThreadInfoColumnTime>
{formatTimestamp(thread?.threadData?.createdAt)}
</ThreadInfoColumnTime>
</ThreadInfoColumn>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
<ThreadSingleTitle
sx={{
fontWeight: shouldAppearLighter && 300,
}}
>
{thread?.threadData?.title}
</ThreadSingleTitle>
{filterMode === "Recently active" && (
<div
style={{
display: "flex",
alignItems: "center",
}}
>
<ThreadSingleLastMessageP>
<ThreadSingleLastMessageSpanP>
last message:{" "}
</ThreadSingleLastMessageSpanP>
{formatDate(thread?.created)}
</ThreadSingleLastMessageP>
</div>
)}
</div>
<Box onClick={()=> {
setTimeout(() => {
executeEvent("threadFetchMode", {
mode: "last-page"
});
}, 300);
}} sx={{
position: 'absolute',
bottom: '2px',
right: '2px',
borderRadius: '5px',
backgroundColor: '#27282c',
display: 'flex',
gap: '10px',
alignItems: 'center',
padding: '5px',
cursor: 'pointer',
'&:hover': {
background: 'rgba(255, 255, 255, 0.60)'
}
}}>
<Typography sx={{
color: 'white',
fontSize: '12px'
}}>Last page</Typography>
<ArrowForwardIosIcon sx={{
color: 'white',
fontSize: '12px'
}} />
</Box>
</SingleThreadParent>
);
})}
<Box
sx={{
width: "100%",
justifyContent: "center",
}}
>
{listOfThreadsToDisplay.length >= 20 &&
filterMode !== "Recently active" && (
<LazyLoad
onLoadMore={() => getAllThreads(groupId, filterMode, false)}
></LazyLoad>
)}
</Box>
</ThreadContainer>
</ThreadContainerFullWidth>
<LoadingSnackbar
open={isLoading}
info={{
message: "Loading threads... please wait.",
}}
/>
</GroupContainer>
);
};

View File

@ -0,0 +1,799 @@
import {
AppBar,
Button,
Toolbar,
Typography,
Box,
TextField,
} from "@mui/material";
import { styled } from "@mui/system";
export const InstanceContainer = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
width: "100%",
backgroundColor: "var(--color-instance)",
height: "59px",
flexShrink: 0,
justifyContent: "space-between",
}));
export const MailContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
width: "100%",
height: "calc(100vh - 78px)",
overflow: "hidden",
}));
export const MailBody = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
width: "100%",
height: "calc(100% - 59px)",
// overflow: 'auto !important'
}));
export const MailBodyInner = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
width: "50%",
height: "100%",
}));
export const MailBodyInnerHeader = styled(Box)(({ theme }) => ({
display: "flex",
width: "100%",
height: "25px",
marginTop: "50px",
marginBottom: "35px",
justifyContent: "center",
alignItems: "center",
gap: "11px",
}));
export const MailBodyInnerScroll = styled(Box)`
display: flex;
flex-direction: column;
overflow: auto !important;
transition: background-color 0.3s;
height: calc(100% - 110px);
&::-webkit-scrollbar {
width: 8px;
height: 8px;
background-color: transparent; /* Initially transparent */
transition: background-color 0.3s; /* Transition for background color */
}
&::-webkit-scrollbar-thumb {
background-color: transparent; /* Initially transparent */
border-radius: 3px; /* Scrollbar thumb radius */
transition: background-color 0.3s; /* Transition for thumb color */
}
&:hover {
&::-webkit-scrollbar {
background-color: #494747; /* Scrollbar background color on hover */
}
&::-webkit-scrollbar-thumb {
background-color: #ffffff3d; /* Scrollbar thumb color on hover */
}
&::-webkit-scrollbar-thumb:hover {
background-color: #ffffff3d; /* Color when hovering over the thumb */
}
}
`;
export const ComposeContainer = styled(Box)(({ theme }) => ({
display: "flex",
width: "150px",
alignItems: "center",
gap: "7px",
height: "100%",
cursor: "pointer",
transition: "0.2s background-color",
justifyContent: "center",
"&:hover": {
backgroundColor: "rgba(67, 68, 72, 1)",
},
}));
export const ComposeContainerBlank = styled(Box)(({ theme }) => ({
display: "flex",
width: "150px",
alignItems: "center",
gap: "7px",
height: "100%",
}));
export const ComposeP = styled(Typography)(({ theme }) => ({
fontSize: "15px",
fontWeight: 500,
}));
export const ComposeIcon = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
cursor: "pointer",
});
export const ArrowDownIcon = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
cursor: "pointer",
});
export const MailIconImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
});
export const MailMessageRowInfoImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
});
export const SelectInstanceContainer = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "17px",
}));
export const SelectInstanceContainerInner = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "3px",
cursor: "pointer",
padding: "8px",
transition: "all 0.2s",
"&:hover": {
borderRadius: "8px",
background: "#434448",
},
}));
export const SelectInstanceContainerFilterInner = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "3px",
cursor: "pointer",
padding: "8px",
transition: "all 0.2s"
}));
export const InstanceLabel = styled(Typography)(({ theme }) => ({
fontSize: "16px",
fontWeight: 500,
color: "#FFFFFF33",
}));
export const InstanceP = styled(Typography)(({ theme }) => ({
fontSize: "16px",
fontWeight: 500,
}));
export const MailMessageRowContainer = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
cursor: "pointer",
justifyContent: "space-between",
borderRadius: "56px 5px 10px 56px",
paddingRight: "15px",
transition: "background 0.2s",
gap: "10px",
"&:hover": {
background: "#434448",
},
}));
export const MailMessageRowProfile = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
cursor: "pointer",
justifyContent: "flex-start",
gap: "10px",
width: "50%",
overflow: "hidden",
}));
export const MailMessageRowInfo = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
cursor: "pointer",
justifyContent: "flex-start",
gap: "7px",
width: "50%",
}));
export const MailMessageRowInfoStatusNotDecrypted = styled(Typography)(
({ theme }) => ({
fontSize: "16px",
fontWeight: 900,
textTransform: "uppercase",
paddingTop: "2px",
})
);
export const MailMessageRowInfoStatusRead = styled(Typography)(({ theme }) => ({
fontSize: "16px",
fontWeight: 300,
}));
export const MessageExtraInfo = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
gap: "2px",
overflow: "hidden",
}));
export const MessageExtraName = styled(Typography)(({ theme }) => ({
fontSize: "16px",
fontWeight: 900,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
}));
export const MessageExtraDate = styled(Typography)(({ theme }) => ({
fontSize: "15px",
fontWeight: 500,
}));
export const MessagesContainer = styled(Box)(({ theme }) => ({
width: "460px",
maxWidth: "90%",
display: "flex",
flexDirection: "column",
gap: "12px",
}));
export const InstanceListParent = styled(Box)`
display: flex;
flex-direction: column;
width: 100%;
min-height: 246px;
max-height: 325px;
width: 425px;
padding: 10px 0px 7px 0px;
background-color: var(--color-instance-popover-bg);
border: 1px solid rgba(0, 0, 0, 0.1);
`;
export const InstanceListHeader = styled(Box)`
display: flex;
flex-direction: column;
width: 100%;
background-color: var(--color-instance-popover-bg);
`;
export const InstanceFooter = styled(Box)`
display: flex;
flex-direction: column;
width: 100%;
flex-shrink: 0;
`;
export const InstanceListContainer = styled(Box)`
width: 100%;
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: auto !important;
transition: background-color 0.3s;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
background-color: transparent; /* Initially transparent */
transition: background-color 0.3s; /* Transition for background color */
}
&::-webkit-scrollbar-thumb {
background-color: transparent; /* Initially transparent */
border-radius: 3px; /* Scrollbar thumb radius */
transition: background-color 0.3s; /* Transition for thumb color */
}
&:hover {
&::-webkit-scrollbar {
background-color: #494747; /* Scrollbar background color on hover */
}
&::-webkit-scrollbar-thumb {
background-color: #ffffff3d; /* Scrollbar thumb color on hover */
}
&::-webkit-scrollbar-thumb:hover {
background-color: #ffffff3d; /* Color when hovering over the thumb */
}
}
`;
export const InstanceListContainerRowLabelContainer = styled(Box)(
({ theme }) => ({
width: "100%",
display: "flex",
alignItems: "center",
gap: "10px",
height: "50px",
})
);
export const InstanceListContainerRow = styled(Box)(({ theme }) => ({
width: "100%",
display: "flex",
alignItems: "center",
gap: "10px",
height: "50px",
cursor: "pointer",
transition: "0.2s background",
"&:hover": {
background: "rgba(67, 68, 72, 1)",
},
flexShrink: 0,
}));
export const InstanceListContainerRowCheck = styled(Box)(({ theme }) => ({
width: "47px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}));
export const InstanceListContainerRowMain = styled(Box)(({ theme }) => ({
display: "flex",
justifyContent: "space-between",
width: "100%",
alignItems: "center",
paddingRight: "30px",
overflow: "hidden",
}));
export const CloseParent = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "20px",
}));
export const InstanceListContainerRowMainP = styled(Typography)(
({ theme }) => ({
fontWeight: 500,
fontSize: "16px",
textOverflow: "ellipsis",
overflow: "hidden",
})
);
export const InstanceListContainerRowCheckIcon = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
});
export const InstanceListContainerRowGroupIcon = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
});
export const TypeInAliasTextfield = styled(TextField)({
width: "340px", // Adjust the width as needed
borderRadius: "5px",
backgroundColor: "rgba(30, 30, 32, 1)",
border: "none",
outline: "none",
input: {
fontSize: 16,
color: "white",
"&::placeholder": {
fontSize: 16,
color: "rgba(255, 255, 255, 0.2)",
},
border: "none",
outline: "none",
padding: "10px",
},
"& .MuiOutlinedInput-root": {
"& fieldset": {
border: "none",
},
"&:hover fieldset": {
border: "none",
},
"&.Mui-focused fieldset": {
border: "none",
},
},
"& .MuiInput-underline:before": {
borderBottom: "none",
},
"& .MuiInput-underline:hover:not(.Mui-disabled):before": {
borderBottom: "none",
},
"& .MuiInput-underline:after": {
borderBottom: "none",
},
});
export const NewMessageCloseImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
cursor: "pointer",
});
export const NewMessageHeaderP = styled(Typography)(({ theme }) => ({
fontSize: "18px",
fontWeight: 600,
}));
export const NewMessageInputRow = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: "3px solid rgba(237, 239, 241, 1)",
width: "100%",
paddingBottom: "6px",
}));
export const NewMessageInputLabelP = styled(Typography)`
color: rgba(84, 84, 84, 0.7);
font-size: 20px;
font-style: normal;
font-weight: 400;
line-height: 120%; /* 24px */
letter-spacing: 0.15px;
`;
export const AliasLabelP = styled(Typography)`
color: rgba(84, 84, 84, 0.7);
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 120%; /* 24px */
letter-spacing: 0.15px;
transition: color 0.2s;
cursor: pointer;
&:hover {
color: rgba(43, 43, 43, 1);
}
`;
export const NewMessageAliasContainer = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "12px",
}));
export const AttachmentContainer = styled(Box)(({ theme }) => ({
height: "36px",
width: "100%",
display: "flex",
alignItems: "center",
}));
export const NewMessageAttachmentImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
cursor: "pointer",
padding: "10px",
border: "1px dashed #646464",
});
export const NewMessageSendButton = styled(Box)`
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.9);
display: inline-flex;
padding: 8px 16px 8px 12px;
justify-content: center;
align-items: center;
gap: 8px;
width: fit-content;
transition: all 0.2s;
color: black;
min-width: 120px;
gap: 8px;
position: relative;
cursor: pointer;
&:hover {
background-color: rgba(41, 41, 43, 1);
color: white;
svg path {
fill: white; // Fill color changes to white on hover
}
}
`;
export const NewMessageSendP = styled(Typography)`
font-family: Roboto;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 120%; /* 19.2px */
letter-spacing: -0.16px;
`;
export const ShowMessageNameP = styled(Typography)`
font-family: Roboto;
font-size: 16px;
font-weight: 900;
line-height: 19px;
letter-spacing: 0em;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
export const ShowMessageTimeP = styled(Typography)`
color: rgba(255, 255, 255, 0.5);
font-family: Roboto;
font-size: 15px;
font-style: normal;
font-weight: 500;
line-height: normal;
`;
export const ShowMessageSubjectP = styled(Typography)`
font-family: Roboto;
font-size: 16px;
font-weight: 500;
line-height: 19px;
letter-spacing: 0.0075em;
text-align: left;
`;
export const ShowMessageButton = styled(Box)`
display: inline-flex;
padding: 8px 16px 8px 16px;
align-items: center;
justify-content: center;
gap: 8px;
width: fit-content;
transition: all 0.2s;
color: white;
background-color: rgba(41, 41, 43, 1)
min-width: 120px;
gap: 8px;
border-radius: 4px;
border: 0.5px solid rgba(255, 255, 255, 0.70);
font-family: Roboto;
min-width: 120px;
cursor: pointer;
&:hover {
border-radius: 4px;
border: 0.5px solid rgba(255, 255, 255, 0.70);
background: #434448;
}
`;
export const ShowMessageReturnButton = styled(Box)`
display: inline-flex;
padding: 8px 16px 8px 16px;
align-items: center;
justify-content: center;
gap: 8px;
width: fit-content;
transition: all 0.2s;
color: white;
background-color: rgba(41, 41, 43, 1)
min-width: 120px;
gap: 8px;
border-radius: 4px;
font-family: Roboto;
min-width: 120px;
cursor: pointer;
&:hover {
border-radius: 4px;
background: #434448;
}
`;
export const ShowMessageButtonP = styled(Typography)`
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 120%; /* 19.2px */
letter-spacing: -0.16px;
color: white;
`;
export const ShowMessageButtonImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
cursor: "pointer",
});
export const MailAttachmentImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
});
export const AliasAvatarImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
});
export const MoreImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
transition: "0.2s all",
"&:hover": {
transform: "scale(1.3)",
},
});
export const MoreP = styled(Typography)`
color: rgba(255, 255, 255, 0.5);
/* Attachments */
font-family: Roboto;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 120%; /* 19.2px */
letter-spacing: -0.16px;
white-space: nowrap;
`;
export const ThreadContainerFullWidth = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
width: "100%",
alignItems: "center",
}));
export const ThreadContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
width: "100%",
maxWidth: "95%",
}));
export const GroupNameP = styled(Typography)`
color: #fff;
font-size: 25px;
font-style: normal;
font-weight: 700;
line-height: 120%; /* 30px */
letter-spacing: 0.188px;
`;
export const AllThreadP = styled(Typography)`
color: #FFF;
font-size: 20px;
font-style: normal;
font-weight: 400;
line-height: 120%; /* 24px */
letter-spacing: 0.15px;
`;
export const SingleThreadParent = styled(Box)`
border-radius: 35px 4px 4px 35px;
position: relative;
background: #434448;
display: flex;
padding: 13px;
cursor: pointer;
margin-bottom: 5px;
height: 76px;
align-items:center;
transition: 0.2s all;
&:hover {
background: rgba(255, 255, 255, 0.20)
}
`;
export const SingleTheadMessageParent = styled(Box)`
border-radius: 35px 4px 4px 35px;
background: #434448;
display: flex;
padding: 13px;
cursor: pointer;
margin-bottom: 5px;
height: 76px;
align-items:center;
`;
export const ThreadInfoColumn = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
width: "170px",
gap: '2px',
marginLeft: '10px',
height: '100%',
justifyContent: 'center'
}));
export const ThreadInfoColumnNameP = styled(Typography)`
color: #FFF;
font-family: Roboto;
font-size: 16px;
font-style: normal;
font-weight: 900;
line-height: normal;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
export const ThreadInfoColumnbyP = styled('span')`
color: rgba(255, 255, 255, 0.80);
font-family: Roboto;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
`;
export const ThreadInfoColumnTime = styled(Typography)`
color: rgba(255, 255, 255, 0.80);
font-family: Roboto;
font-size: 15px;
font-style: normal;
font-weight: 500;
line-height: normal;
`
export const ThreadSingleTitle = styled(Typography)`
color: #FFF;
font-family: Roboto;
font-size: 23px;
font-style: normal;
font-weight: 700;
line-height: normal;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`
export const ThreadSingleLastMessageP = styled(Typography)`
color: #FFF;
font-family: Roboto;
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: normal;
`
export const ThreadSingleLastMessageSpanP = styled('span')`
color: #FFF;
font-family: Roboto;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
`;
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;
}
`
export const CloseContainer = styled(Box)(({ theme }) => ({
display: "flex",
width: "50px",
overflow: "hidden",
alignItems: "center",
cursor: "pointer",
transition: "0.2s background-color",
justifyContent: "center",
position: 'absolute',
top: '0px',
right: '0px',
height: '50px',
borderRadius: '0px 12px 0px 0px',
"&:hover": {
backgroundColor: "rgba(162, 31, 31, 1)",
},
}));

View File

@ -0,0 +1,554 @@
import React, { useEffect, useRef, useState } from "react";
import { Box, Button, CircularProgress, Input, Typography } from "@mui/material";
import ShortUniqueId from "short-unique-id";
import CloseIcon from "@mui/icons-material/Close";
import ModalCloseSVG from "../../../assets/svgs/ModalClose.svg";
import ComposeIconSVG from "../../../assets/svgs/ComposeIcon.svg";
import {
AttachmentContainer,
CloseContainer,
ComposeContainer,
ComposeIcon,
ComposeP,
InstanceFooter,
InstanceListContainer,
InstanceListHeader,
NewMessageAttachmentImg,
NewMessageCloseImg,
NewMessageHeaderP,
NewMessageInputRow,
NewMessageSendButton,
NewMessageSendP,
} from "./Mail-styles";
import { ReusableModal } from "./ReusableModal";
import { Spacer } from "../../../common/Spacer";
import { formatBytes } from "../../../utils/Size";
import { CreateThreadIcon } from "../../../assets/svgs/CreateThreadIcon";
import { SendNewMessage } from "../../../assets/svgs/SendNewMessage";
import { TextEditor } from "./TextEditor";
import { MyContext, pauseAllQueues, resumeAllQueues } from "../../../App";
import { getFee } from "../../../background";
import TipTap from "../../Chat/TipTap";
import { MessageDisplay } from "../../Chat/MessageDisplay";
import { CustomizedSnackbars } from "../../Snackbar/Snackbar";
import { saveTempPublish } from "../../Chat/GroupAnnouncements";
const uid = new ShortUniqueId({ length: 8 });
export const toBase64 = (file: File): Promise<string | ArrayBuffer | null> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => {
reject(error);
};
});
export function objectToBase64(obj: any) {
// Step 1: Convert the object to a JSON string
const jsonString = JSON.stringify(obj);
// Step 2: Create a Blob from the JSON string
const blob = new Blob([jsonString], { type: "application/json" });
// Step 3: Create a FileReader to read the Blob as a base64-encoded string
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result === "string") {
// Remove 'data:application/json;base64,' prefix
const base64 = reader.result.replace(
"data:application/json;base64,",
""
);
resolve(base64);
} else {
reject(new Error("Failed to read the Blob as a base64-encoded string"));
}
};
reader.onerror = () => {
reject(reader.error);
};
reader.readAsDataURL(blob);
});
}
interface NewMessageProps {
hideButton?: boolean;
groupInfo: any;
currentThread?: any;
isMessage?: boolean;
messageCallback?: (val: any) => void;
publishCallback?: () => void;
refreshLatestThreads?: () => void;
members: any;
}
export const publishGroupEncryptedResource = async ({
encryptedData,
identifier,
}) => {
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "publishGroupEncryptedResource",
payload: {
encryptedData,
identifier,
},
},
(response) => {
if (!response?.error) {
res(response);
return
}
rej(response.error);
}
);
});
};
export const encryptSingleFunc = async (data: string, secretKeyObject: any) => {
try {
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "encryptSingle",
payload: {
data,
secretKeyObject,
},
},
(response) => {
if (!response?.error) {
res(response);
return;
}
rej(response.error);
}
);
});
} catch (error) {}
};
export const NewThread = ({
groupInfo,
members,
currentThread,
isMessage = false,
publishCallback,
userInfo,
getSecretKey,
closeCallback,
postReply,
myName
}: NewMessageProps) => {
const { show } = React.useContext(MyContext);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [value, setValue] = useState("");
const [isSending, setIsSending] = useState(false);
const [threadTitle, setThreadTitle] = useState<string>("");
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const editorRef = useRef(null);
const setEditorRef = (editorInstance) => {
editorRef.current = editorInstance;
};
useEffect(() => {
if (postReply) {
setIsOpen(true);
}
}, [postReply]);
const closeModal = () => {
setIsOpen(false);
setValue("");
};
async function publishQDNResource() {
try {
pauseAllQueues()
if(isSending) return
setIsSending(true)
let name: string = "";
let errorMsg = "";
name = userInfo?.name || "";
const missingFields: string[] = [];
if (!isMessage && !threadTitle) {
errorMsg = "Please provide a thread title";
}
if (!name) {
errorMsg = "Cannot send a message without a access to your name";
}
if (!groupInfo) {
errorMsg = "Cannot access group information";
}
// if (!description) missingFields.push('subject')
if (missingFields.length > 0) {
const missingFieldsString = missingFields.join(", ");
const errMsg = `Missing: ${missingFieldsString}`;
errorMsg = errMsg;
}
if (errorMsg) {
// dispatch(
// setNotification({
// msg: errorMsg,
// alertType: "error",
// })
// );
throw new Error(errorMsg);
}
const htmlContent = editorRef.current.getHTML();
if (!htmlContent?.trim() || htmlContent?.trim() === "<p></p>")
throw new Error("Please provide a first message to the thread");
const fee = await getFee("ARBITRARY");
let feeToShow = fee.fee;
if (!isMessage) {
feeToShow = +feeToShow * 2;
}
await show({
message: "Would you like to perform a ARBITRARY transaction?",
publishFee: feeToShow + " QORT",
});
let reply = null;
if (postReply) {
reply = { ...postReply };
if (reply.reply) {
delete reply.reply;
}
}
const mailObject: any = {
createdAt: Date.now(),
version: 1,
textContentV2: htmlContent,
name,
threadOwner: currentThread?.threadData?.name || name,
reply,
};
const secretKey = await getSecretKey();
if (!secretKey) {
throw new Error("Cannot get group secret key");
}
if (!isMessage) {
const idThread = uid.rnd();
const idMsg = uid.rnd();
const messageToBase64 = await objectToBase64(mailObject);
const encryptSingleFirstPost = await encryptSingleFunc(
messageToBase64,
secretKey
);
const threadObject = {
title: threadTitle,
groupId: groupInfo.id,
createdAt: Date.now(),
name,
};
const threadToBase64 = await objectToBase64(threadObject);
const encryptSingleThread = await encryptSingleFunc(
threadToBase64,
secretKey
);
let identifierThread = `grp-${groupInfo.groupId}-thread-${idThread}`;
await publishGroupEncryptedResource({
identifier: identifierThread,
encryptedData: encryptSingleThread,
});
let identifierPost = `thmsg-${identifierThread}-${idMsg}`;
await publishGroupEncryptedResource({
identifier: identifierPost,
encryptedData: encryptSingleFirstPost,
});
const dataToSaveToStorage = {
name: myName,
identifier: identifierThread,
service: 'DOCUMENT',
tempData: threadObject,
created: Date.now(),
}
const dataToSaveToStoragePost = {
name: myName,
identifier: identifierPost,
service: 'DOCUMENT',
tempData: mailObject,
created: Date.now(),
threadId: identifierThread
}
await saveTempPublish({data: dataToSaveToStorage, key: 'thread'})
await saveTempPublish({data: dataToSaveToStoragePost, key: 'thread-post'})
setInfoSnack({
type: "success",
message: "Successfully created thread. It may take some time for the publish to propagate",
});
setOpenSnack(true)
// dispatch(
// setNotification({
// msg: "Message sent",
// alertType: "success",
// })
// );
if (publishCallback) {
publishCallback()
// threadCallback({
// threadData: threadObject,
// threadOwner: name,
// name,
// threadId: identifierThread,
// created: Date.now(),
// service: 'MAIL_PRIVATE',
// identifier: identifier
// })
}
closeModal();
} else {
if (!currentThread) throw new Error("unable to locate thread Id");
const idThread = currentThread.threadId;
const messageToBase64 = await objectToBase64(mailObject);
const encryptSinglePost = await encryptSingleFunc(
messageToBase64,
secretKey
);
const idMsg = uid.rnd();
let identifier = `thmsg-${idThread}-${idMsg}`;
const res = await publishGroupEncryptedResource({
identifier: identifier,
encryptedData: encryptSinglePost,
});
const dataToSaveToStoragePost = {
threadId: idThread,
name: myName,
identifier: identifier,
service: 'DOCUMENT',
tempData: mailObject,
created: Date.now()
}
await saveTempPublish({data: dataToSaveToStoragePost, key: 'thread-post'})
// await qortalRequest(multiplePublishMsg);
// dispatch(
// setNotification({
// msg: "Message sent",
// alertType: "success",
// })
// );
setInfoSnack({
type: "success",
message: "Successfully created post. It may take some time for the publish to propagate",
});
setOpenSnack(true)
if(publishCallback){
publishCallback()
}
// messageCallback({
// identifier,
// id: identifier,
// name,
// service: MAIL_SERVICE_TYPE,
// created: Date.now(),
// ...mailObject,
// });
}
closeModal();
} catch (error: any) {
if(error?.message){
setInfoSnack({
type: "error",
message: error?.message,
});
setOpenSnack(true)
}
} finally {
setIsSending(false);
resumeAllQueues()
}
}
const sendMail = () => {
publishQDNResource();
};
return (
<Box
sx={{
display: "flex",
}}
>
<ComposeContainer
sx={{
padding: "15px",
}}
onClick={() => setIsOpen(true)}
>
<ComposeIcon src={ComposeIconSVG} />
<ComposeP>{currentThread ? "New Post" : "New Thread"}</ComposeP>
</ComposeContainer>
<ReusableModal
open={isOpen}
customStyles={{
maxHeight: "95vh",
maxWidth: "950px",
height: "700px",
borderRadius: "12px 12px 0px 0px",
background: "#434448",
padding: "0px",
gap: "0px",
}}
>
<InstanceListHeader
sx={{
height: "50px",
padding: "20px 42px",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: "#434448",
}}
>
<NewMessageHeaderP>
{isMessage ? "Post Message" : "New Thread"}
</NewMessageHeaderP>
<CloseContainer onClick={closeModal}>
<NewMessageCloseImg src={ModalCloseSVG} />
</CloseContainer>
</InstanceListHeader>
<InstanceListContainer
sx={{
backgroundColor: "#434448",
padding: "20px 42px",
height: "calc(100% - 150px)",
flexShrink: 0,
}}
>
{!isMessage && (
<>
<Spacer height="10px" />
<NewMessageInputRow>
<Input
id="standard-adornment-name"
value={threadTitle}
onChange={(e) => {
setThreadTitle(e.target.value);
}}
placeholder="Thread Title"
disableUnderline
autoComplete="off"
autoCorrect="off"
sx={{
width: "100%",
color: "white",
"& .MuiInput-input::placeholder": {
color: "rgba(255,255,255, 0.70) !important",
fontSize: "20px",
fontStyle: "normal",
fontWeight: 400,
lineHeight: "120%", // 24px
letterSpacing: "0.15px",
opacity: 1,
},
"&:focus": {
outline: "none",
},
// Add any additional styles for the input here
}}
/>
</NewMessageInputRow>
</>
)}
{postReply && postReply.textContentV2 && (
<Box
sx={{
width: "100%",
maxHeight: "120px",
overflow: "auto",
}}
>
<MessageDisplay htmlContent={postReply?.textContentV2} />
</Box>
)}
<Spacer height="30px" />
<Box
sx={{
maxHeight: "40vh",
}}
>
<TipTap
setEditorRef={setEditorRef}
onEnter={sendMail}
disableEnter
/>
{/* <TextEditor
inlineContent={value}
setInlineContent={(val: any) => {
setValue(val);
}}
/> */}
</Box>
</InstanceListContainer>
<InstanceFooter
sx={{
backgroundColor: "#434448",
padding: "20px 42px",
alignItems: "center",
height: "90px",
}}
>
<NewMessageSendButton onClick={sendMail}>
{isSending && (
<Box sx={{height: '100%', position: 'absolute', width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center'}}>
<CircularProgress sx={{
}} size={'12px'} />
</Box>
)}
<NewMessageSendP>
{isMessage ? "Post" : "Create Thread"}
</NewMessageSendP>
{isMessage ? (
<SendNewMessage
color="red"
opacity={1}
height="25px"
width="25px"
/>
) : (
<CreateThreadIcon
color="red"
opacity={1}
height="25px"
width="25px"
/>
)}
</NewMessageSendButton>
</InstanceFooter>
</ReusableModal>
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
</Box>
);
};

View File

@ -0,0 +1,129 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createEditor} from 'slate';
import { withReact, Slate, Editable, RenderElementProps, RenderLeafProps } from 'slate-react';
type ExtendedRenderElementProps = RenderElementProps & { mode?: string }
export const renderElement = ({
attributes,
children,
element,
mode
}: ExtendedRenderElementProps) => {
switch (element.type) {
case 'block-quote':
return <blockquote {...attributes}>{children}</blockquote>
case 'heading-2':
return (
<h2
className="h2"
{...attributes}
style={{ textAlign: element.textAlign }}
>
{children}
</h2>
)
case 'heading-3':
return (
<h3
className="h3"
{...attributes}
style={{ textAlign: element.textAlign }}
>
{children}
</h3>
)
case 'code-block':
return (
<pre {...attributes} className="code-block">
<code>{children}</code>
</pre>
)
case 'code-line':
return <div {...attributes}>{children}</div>
case 'link':
return (
<a href={element.url} {...attributes}>
{children}
</a>
)
default:
return (
<p
className={`paragraph${mode ? `-${mode}` : ''}`}
{...attributes}
style={{ textAlign: element.textAlign }}
>
{children}
</p>
)
}
}
export const renderLeaf = ({ attributes, children, leaf }: RenderLeafProps) => {
let el = children
if (leaf.bold) {
el = <strong>{el}</strong>
}
if (leaf.italic) {
el = <em>{el}</em>
}
if (leaf.underline) {
el = <u>{el}</u>
}
if (leaf.link) {
el = (
<a href={leaf.link} {...attributes}>
{el}
</a>
)
}
return <span {...attributes}>{el}</span>
}
interface ReadOnlySlateProps {
content: any
mode?: string
}
const ReadOnlySlate: React.FC<ReadOnlySlateProps> = ({ content, mode }) => {
const [load, setLoad] = useState(false)
const editor = useMemo(() => withReact(createEditor()), [])
const value = useMemo(() => content, [content])
const performUpdate = useCallback(async()=> {
setLoad(true)
await new Promise<void>((res)=> {
setTimeout(() => {
res()
}, 250);
})
setLoad(false)
}, [])
useEffect(()=> {
performUpdate()
}, [value])
if(load) return null
return (
<Slate editor={editor} value={value} onChange={() => {}}>
<Editable
readOnly
renderElement={(props) => renderElement({ ...props, mode })}
renderLeaf={renderLeaf}
/>
</Slate>
)
}
export default ReadOnlySlate;

View File

@ -0,0 +1,57 @@
import React from 'react'
import { Box, Modal, useTheme } from '@mui/material'
interface MyModalProps {
open: boolean
onClose?: () => void
onSubmit?: (obj: any) => Promise<void>
children: any
customStyles?: any
}
export const ReusableModal: React.FC<MyModalProps> = ({
open,
onClose,
onSubmit,
children,
customStyles = {}
}) => {
const theme = useTheme()
return (
<Modal
open={open}
onClose={onClose}
aria-labelledby="modal-title"
aria-describedby="modal-description"
componentsProps={{
backdrop: {
style: {
backdropFilter: 'blur(3px)',
},
},
}}
disableAutoFocus
disableEnforceFocus
disableRestoreFocus
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '75%',
bgcolor: theme.palette.primary.main,
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
gap: 2,
...customStyles
}}
>
{children}
</Box>
</Modal>
)
}

View File

@ -0,0 +1,224 @@
import React, { useState } from "react";
import { Avatar, Box, IconButton } from "@mui/material";
import DOMPurify from "dompurify";
import FormatQuoteIcon from '@mui/icons-material/FormatQuote';
import MoreSVG from '../../../assets/svgs/More.svg'
import {
MoreImg,
MoreP,
SingleTheadMessageParent,
ThreadInfoColumn,
ThreadInfoColumnNameP,
ThreadInfoColumnTime,
} from "./Mail-styles";
import { Spacer } from "../../../common/Spacer";
import { DisplayHtml } from "./DisplayHtml";
import { formatTimestampForum } from "../../../utils/time";
import ReadOnlySlate from "./ReadOnlySlate";
import { MessageDisplay } from "../../Chat/MessageDisplay";
import { getBaseApi } from "../../../background";
import { getBaseApiReact } from "../../../App";
export const ShowMessage = ({ message, openNewPostWithQuote }: any) => {
const [expandAttachments, setExpandAttachments] = useState<boolean>(false);
let cleanHTML = "";
if (message?.htmlContent) {
cleanHTML = DOMPurify.sanitize(message.htmlContent);
}
return (
<SingleTheadMessageParent
sx={{
height: "auto",
alignItems: "flex-start",
cursor: "default",
borderRadius: '35px 4px 4px 4px'
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
width: '100%'
}}
>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
gap: "10px",
}}
>
<Avatar sx={{
height: '50px',
width: '50px'
}} src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${message?.name}/qortal_avatar?async=true`} alt={message?.name}>{message?.name?.charAt(0)}</Avatar>
<ThreadInfoColumn>
<ThreadInfoColumnNameP>{message?.name}</ThreadInfoColumnNameP>
<ThreadInfoColumnTime>
{formatTimestampForum(message?.created)}
</ThreadInfoColumnTime>
</ThreadInfoColumn>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
{message?.attachments?.length > 0 && (
<Box
sx={{
width: "100%",
marginTop: "10px",
}}
>
{message?.attachments
.map((file: any, index: number) => {
const isFirst = index === 0
return (
<Box
sx={{
display: expandAttachments ? "flex" : !expandAttachments && isFirst ? 'flex' : 'none',
alignItems: "center",
justifyContent: "flex-start",
width: "100%",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "5px",
cursor: "pointer",
width: "auto",
}}
>
{/* <FileElement
fileInfo={{ ...file, mimeTypeSaved: file?.type }}
title={file?.filename}
mode="mail"
otherUser={message?.user}
>
<MailAttachmentImg src={AttachmentMailSVG} />
<Typography
sx={{
fontSize: "16px",
transition: '0.2s all',
"&:hover": {
color: 'rgba(255, 255, 255, 0.90)',
textDecoration: 'underline'
}
}}
>
{file?.originalFilename || file?.filename}
</Typography>
</FileElement> */}
{message?.attachments?.length > 1 && isFirst && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "5px",
}}
onClick={() => {
setExpandAttachments(prev => !prev);
}}
>
<MoreImg
sx={{
marginLeft: "5px",
transform: expandAttachments
? "rotate(180deg)"
: "unset",
}}
src={MoreSVG}
/>
<MoreP>
{expandAttachments ? 'hide' : `(${message?.attachments?.length - 1} more)`}
</MoreP>
</Box>
)}
</Box>
</Box>
);
})
}
</Box>
)}
</div>
</Box>
<Spacer height="20px" />
{message?.reply?.textContentV2 && (
<>
<Box sx={{
width: '100%',
opacity: 0.7,
borderRadius: '5px',
border: '1px solid gray',
boxSizing: 'border-box',
padding: '5px'
}}>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
gap: "10px",
}}
>
<Avatar sx={{
height: '30px',
width: '30px'
}} src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${message?.reply?.name}/qortal_avatar?async=true`} alt={message?.reply?.name}>{message?.reply?.name?.charAt(0)}</Avatar>
<ThreadInfoColumn>
<ThreadInfoColumnNameP sx={{
fontSize: '14px'
}}>{message?.reply?.name}</ThreadInfoColumnNameP>
</ThreadInfoColumn>
</Box>
<MessageDisplay htmlContent={message?.reply?.textContentV2} />
</Box>
<Spacer height="20px" />
</>
)}
{message?.textContent && (
<ReadOnlySlate content={message.textContent} mode="mail" />
)}
{message?.textContentV2 && (
<MessageDisplay htmlContent={message?.textContentV2} />
)}
{message?.htmlContent && (
<div dangerouslySetInnerHTML={{ __html: cleanHTML }} />
)}
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'flex-end'
}}>
<IconButton
onClick={() => openNewPostWithQuote(message)}
>
<FormatQuoteIcon />
</IconButton>
</Box>
</Box>
</SingleTheadMessageParent>
);
};

View File

@ -0,0 +1,39 @@
import React from "react";
import ReactQuill, { Quill } from "react-quill";
import "react-quill/dist/quill.snow.css";
import ImageResize from "quill-image-resize-module-react";
import './texteditor.css'
Quill.register("modules/imageResize", ImageResize);
const modules = {
imageResize: {
parchment: Quill.import("parchment"),
modules: ["Resize", "DisplaySize"],
},
toolbar: [
["bold", "italic", "underline", "strike"], // styled text
["blockquote", "code-block"], // blocks
[{ header: 1 }, { header: 2 }], // custom button values
[{ list: "ordered" }, { list: "bullet" }], // lists
[{ script: "sub" }, { script: "super" }], // superscript/subscript
[{ indent: "-1" }, { indent: "+1" }], // outdent/indent
[{ direction: "rtl" }], // text direction
[{ size: ["small", false, "large", "huge"] }], // custom dropdown
[{ header: [1, 2, 3, 4, 5, 6, false] }], // custom button values
[{ color: [] }, { background: [] }], // dropdown with defaults
[{ font: [] }], // font family
[{ align: [] }], // text align
["clean"], // remove formatting
// ["image"], // image
],
};
export const TextEditor = ({ inlineContent, setInlineContent }: any) => {
return (
<ReactQuill
theme="snow"
value={inlineContent}
onChange={setInlineContent}
modules={modules}
/>
);
};

View File

@ -0,0 +1,329 @@
import React, {
FC,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import {
Box,
Skeleton,
} from '@mui/material'
import { ShowMessage } from './ShowMessageWithoutModal'
// import {
// setIsLoadingCustom,
// } from '../../state/features/globalSlice'
import { ComposeP, GroupContainer, GroupNameP, MailIconImg, ShowMessageReturnButton, SingleThreadParent, ThreadContainer, ThreadContainerFullWidth } from './Mail-styles'
import { Spacer } from '../../../common/Spacer'
import { threadIdentifier } from './GroupMail'
import LazyLoad from '../../../common/LazyLoad'
import ReturnSVG from '../../../assets/svgs/Return.svg'
import { NewThread } from './NewThread'
import { decryptPublishes } from '../../Chat/GroupAnnouncements'
import { getBaseApi } from '../../../background'
import { getBaseApiReact } from '../../../App'
interface ThreadProps {
currentThread: any
groupInfo: any
closeThread: () => void
members: any
}
const getEncryptedResource = async ({name, identifier, secretKey})=> {
const res = await fetch(
`${getBaseApiReact()}/arbitrary/DOCUMENT/${name}/${identifier}?encoding=base64`
);
const data = await res.text();
const response = await decryptPublishes([{ data }], secretKey);
const messageData = response[0];
return messageData.decryptedData
}
export const Thread = ({
currentThread,
groupInfo,
closeThread,
members,
userInfo,
secretKey,
getSecretKey
}: ThreadProps) => {
const [messages, setMessages] = useState<any[]>([])
const [hashMapMailMessages, setHashMapMailMessages] = useState({})
const secretKeyRef = useRef(null)
useEffect(() => {
secretKeyRef.current = secretKey;
}, [secretKey]);
const getIndividualMsg = async (message: any) => {
try {
const responseDataMessage = await getEncryptedResource({identifier: message.identifier, name: message.name, secretKey})
const fullObject = {
...message,
...(responseDataMessage || {}),
id: message.identifier
}
setHashMapMailMessages((prev)=> {
return {
...prev,
[message.identifier]: fullObject
}
})
} catch (error) {}
}
const getMailMessages = React.useCallback(
async (groupInfo: any, reset?: boolean, hideAlert?: boolean) => {
try {
if(!hideAlert){
// dispatch(setIsLoadingCustom('Loading messages'))
}
let threadId = groupInfo.threadId
const offset = messages.length
const identifier = `thmsg-${threadId}`
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=20&includemetadata=false&offset=${offset}&reverse=true&prefix=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
let fullArrayMsg = reset ? [] : [...messages]
let newMessages: any[] = []
for (const message of responseData) {
const index = fullArrayMsg.findIndex(
(p) => p.identifier === message.identifier
)
if (index !== -1) {
fullArrayMsg[index] = message
} else {
fullArrayMsg.push(message)
getIndividualMsg(message)
}
}
setMessages(fullArrayMsg)
} catch (error) {
} finally {
if(!hideAlert){
// dispatch(setIsLoadingCustom(null))
}
}
},
[messages, secretKey]
)
const getMessages = React.useCallback(async () => {
if (!currentThread || !secretKey) return
await getMailMessages(currentThread, true)
}, [getMailMessages, currentThread, secretKey])
const firstMount = useRef(false)
const saveTimestamp = useCallback((currentThread: any, username?: string)=> {
if(!currentThread?.threadData?.groupId || !currentThread?.threadId || !username) return
const threadIdForLocalStorage = `qmail_threads_${currentThread?.threadData?.groupId}_${currentThread?.threadId}`
const threads = JSON.parse(
localStorage.getItem(`qmail_threads_viewedtimestamp_${username}`) || "{}"
);
// Convert to an array of objects with identifier and all fields
let dataArray = Object.entries(threads).map(([identifier, value]) => ({
identifier,
...(value as any),
}));
// Sort the array based on timestamp in descending order
dataArray.sort((a, b) => b.timestamp - a.timestamp);
// Slice the array to keep only the first 500 elements
let latest500 = dataArray.slice(0, 500);
// Convert back to the original object format
let latest500Data: any = {};
latest500.forEach(item => {
const { identifier, ...rest } = item;
latest500Data[identifier] = rest;
});
latest500Data[threadIdForLocalStorage] = {
timestamp: Date.now(),
}
localStorage.setItem(
`qmail_threads_viewedtimestamp_${username}`,
JSON.stringify(latest500Data)
);
}, [])
useEffect(() => {
if (currentThread && secretKey) {
getMessages()
firstMount.current = true
// saveTimestamp(currentThread, user.name)
}
}, [ currentThread, secretKey])
const messageCallback = useCallback((msg: any) => {
// dispatch(addToHashMapMail(msg))
setMessages((prev) => [msg, ...prev])
}, [])
const interval = useRef<any>(null)
const checkNewMessages = React.useCallback(
async (groupInfo: any) => {
try {
let threadId = groupInfo.threadId
const identifier = `thmsg-${threadId}`
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=20&includemetadata=false&offset=${0}&reverse=true&prefix=true`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const latestMessage = messages[0]
if (!latestMessage) return
const findMessage = responseData?.findIndex(
(item: any) => item?.identifier === latestMessage?.identifier
)
let sliceLength = responseData.length
if (findMessage !== -1) {
sliceLength = findMessage
}
const newArray = responseData.slice(0, findMessage).reverse()
let fullArrayMsg = [...messages]
for (const message of newArray) {
try {
const responseDataMessage = await getEncryptedResource({identifier: message.identifier, name: message.name, secretKey: secretKeyRef.current})
const fullObject = {
...message,
...(responseDataMessage || {}),
id: message.identifier
}
setHashMapMailMessages((prev)=> {
return {
...prev,
[message.identifier]: fullObject
}
})
const index = messages.findIndex(
(p) => p.identifier === fullObject.identifier
)
if (index !== -1) {
fullArrayMsg[index] = fullObject
} else {
fullArrayMsg.unshift(fullObject)
}
} catch (error) {}
}
setMessages(fullArrayMsg)
} catch (error) {
} finally {
}
},
[messages]
)
const checkNewMessagesFunc = useCallback(() => {
let isCalling = false
interval.current = setInterval(async () => {
if (isCalling) return
isCalling = true
const res = await checkNewMessages(currentThread)
isCalling = false
}, 8000)
}, [checkNewMessages, currentThread])
useEffect(() => {
checkNewMessagesFunc()
return () => {
if (interval?.current) {
clearInterval(interval.current)
}
}
}, [checkNewMessagesFunc])
if (!currentThread) return null
return (
<GroupContainer
sx={{
position: "relative",
overflow: 'auto',
width: '100%'
}}
>
<NewThread
groupInfo={groupInfo}
isMessage={true}
currentThread={currentThread}
messageCallback={messageCallback}
members={members}
userInfo={userInfo}
getSecretKey={getSecretKey}
/>
<ThreadContainerFullWidth>
<ThreadContainer>
<Spacer height="30px" />
<Box sx={{
width: '100%',
alignItems: 'center',
display: 'flex',
justifyContent: 'space-between'
}}>
<GroupNameP>{currentThread?.threadData?.title}</GroupNameP>
<ShowMessageReturnButton onClick={() => {
setMessages([])
closeThread()
}}>
<MailIconImg src={ReturnSVG} />
<ComposeP>Return to Threads</ComposeP>
</ShowMessageReturnButton>
</Box>
<Spacer height="60px" />
{messages.map((message) => {
let fullMessage = message
if (hashMapMailMessages[message?.identifier]) {
fullMessage = hashMapMailMessages[message.identifier]
return <ShowMessage key={message?.identifier} message={fullMessage} />
}
return (
<SingleThreadParent>
<Skeleton
variant="rectangular"
style={{
width: '100%',
height: 60,
borderRadius: '8px',
overflow: 'hidden'
}}
/>
</SingleThreadParent>
)
})}
</ThreadContainer>
</ThreadContainerFullWidth>
{messages.length >= 20 && (
<LazyLoad onLoadMore={()=> getMailMessages(currentThread, false, true)}></LazyLoad>
)}
</GroupContainer>
)
}

View File

@ -0,0 +1,663 @@
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, IconButton, Skeleton } from "@mui/material";
import { ShowMessage } from "./ShowMessageWithoutModal";
// import {
// setIsLoadingCustom,
// } from '../../state/features/globalSlice'
import {
ComposeP,
GroupContainer,
GroupNameP,
MailIconImg,
ShowMessageReturnButton,
SingleThreadParent,
ThreadContainer,
ThreadContainerFullWidth,
} from "./Mail-styles";
import { Spacer } from "../../../common/Spacer";
import { threadIdentifier } from "./GroupMail";
import LazyLoad from "../../../common/LazyLoad";
import ReturnSVG from "../../../assets/svgs/Return.svg";
import { NewThread } from "./NewThread";
import { decryptPublishes, getTempPublish } from "../../Chat/GroupAnnouncements";
import { LoadingSnackbar } from "../../Snackbar/LoadingSnackbar";
import { subscribeToEvent, unsubscribeFromEvent } from "../../../utils/events";
import RefreshIcon from "@mui/icons-material/Refresh";
import { getBaseApi } from "../../../background";
import { getBaseApiReact } from "../../../App";
interface ThreadProps {
currentThread: any;
groupInfo: any;
closeThread: () => void;
members: any;
}
const getEncryptedResource = async ({ name, identifier, secretKey }) => {
const res = await fetch(
`${getBaseApiReact()}/arbitrary/DOCUMENT/${name}/${identifier}?encoding=base64`
);
const data = await res.text();
const response = await decryptPublishes([{ data }], secretKey);
const messageData = response[0];
return messageData.decryptedData;
};
export const Thread = ({
currentThread,
groupInfo,
closeThread,
members,
userInfo,
secretKey,
getSecretKey,
updateThreadActivityCurrentThread
}: ThreadProps) => {
const [tempPublishedList, setTempPublishedList] = useState([])
const [messages, setMessages] = useState<any[]>([]);
const [hashMapMailMessages, setHashMapMailMessages] = useState({});
const [hasFirstPage, setHasFirstPage] = useState(false);
const [hasPreviousPage, setHasPreviousPage] = useState(false);
const [hasNextPage, setHasNextPage] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [postReply, setPostReply] = useState(null);
const [hasLastPage, setHasLastPage] = useState(false);
const secretKeyRef = useRef(null);
const currentThreadRef = useRef(null);
const containerRef = useRef(null);
useEffect(() => {
currentThreadRef.current = currentThread;
}, [currentThread]);
useEffect(() => {
secretKeyRef.current = secretKey;
}, [secretKey]);
const getIndividualMsg = async (message: any) => {
try {
const responseDataMessage = await getEncryptedResource({
identifier: message.identifier,
name: message.name,
secretKey,
});
const fullObject = {
...message,
...(responseDataMessage || {}),
id: message.identifier,
};
setHashMapMailMessages((prev) => {
return {
...prev,
[message.identifier]: fullObject,
};
});
} catch (error) {}
};
const setTempData = async ()=> {
try {
let threadId = currentThread.threadId;
const keyTemp = 'thread-post'
const getTempAnnouncements = await getTempPublish()
if(getTempAnnouncements?.[keyTemp]){
let tempData = []
Object.keys(getTempAnnouncements?.[keyTemp] || {}).map((key)=> {
const value = getTempAnnouncements?.[keyTemp][key]
if(value.data?.threadId === threadId){
tempData.push(value.data)
}
})
setTempPublishedList(tempData)
}
} catch (error) {
}
}
const getMailMessages = React.useCallback(
async (groupInfo: any, before, after, isReverse) => {
try {
setTempPublishedList([])
setIsLoading(true);
setHasFirstPage(false);
setHasPreviousPage(false);
setHasLastPage(false);
setHasNextPage(false);
let threadId = groupInfo.threadId;
const identifier = `thmsg-${threadId}`;
let url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=20&includemetadata=false&prefix=true`;
if (!isReverse) {
url = url + "&reverse=false";
}
if (isReverse) {
url = url + "&reverse=true";
}
if (after) {
url = url + `&after=${after}`;
}
if (before) {
url = url + `&before=${before}`;
}
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
let fullArrayMsg = [...responseData];
if (isReverse) {
fullArrayMsg = fullArrayMsg.reverse();
}
// let newMessages: any[] = []
for (const message of responseData) {
getIndividualMsg(message);
}
setMessages(fullArrayMsg);
if (before === null && after === null && isReverse) {
setTimeout(() => {
containerRef.current.scrollIntoView({ behavior: "smooth" });
}, 300);
}
if (fullArrayMsg.length === 0){
setTempData()
return;
}
// check if there are newer posts
const urlNewer = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=1&includemetadata=false&reverse=false&prefix=true&before=${fullArrayMsg[0].created}`;
const responseNewer = await fetch(urlNewer, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataNewer = await responseNewer.json();
if (responseDataNewer.length > 0) {
setHasFirstPage(true);
setHasPreviousPage(true);
} else {
setHasFirstPage(false);
setHasPreviousPage(false);
}
// check if there are older posts
const urlOlder = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=1&includemetadata=false&reverse=false&prefix=true&after=${
fullArrayMsg[fullArrayMsg.length - 1].created
}`;
const responseOlder = await fetch(urlOlder, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataOlder = await responseOlder.json();
if (responseDataOlder.length > 0) {
setHasLastPage(true);
setHasNextPage(true);
} else {
setHasLastPage(false);
setHasNextPage(false);
setTempData()
updateThreadActivityCurrentThread()
}
} catch (error) {
} finally {
setIsLoading(false);
}
},
[messages, secretKey]
);
const getMessages = React.useCallback(async () => {
if (!currentThread || !secretKey) return;
await getMailMessages(currentThread, null, null, false);
}, [getMailMessages, currentThread, secretKey]);
const firstMount = useRef(false);
const saveTimestamp = useCallback((currentThread: any, username?: string) => {
if (
!currentThread?.threadData?.groupId ||
!currentThread?.threadId ||
!username
)
return;
const threadIdForLocalStorage = `qmail_threads_${currentThread?.threadData?.groupId}_${currentThread?.threadId}`;
const threads = JSON.parse(
localStorage.getItem(`qmail_threads_viewedtimestamp_${username}`) || "{}"
);
// Convert to an array of objects with identifier and all fields
let dataArray = Object.entries(threads).map(([identifier, value]) => ({
identifier,
...(value as any),
}));
// Sort the array based on timestamp in descending order
dataArray.sort((a, b) => b.timestamp - a.timestamp);
// Slice the array to keep only the first 500 elements
let latest500 = dataArray.slice(0, 500);
// Convert back to the original object format
let latest500Data: any = {};
latest500.forEach((item) => {
const { identifier, ...rest } = item;
latest500Data[identifier] = rest;
});
latest500Data[threadIdForLocalStorage] = {
timestamp: Date.now(),
};
localStorage.setItem(
`qmail_threads_viewedtimestamp_${username}`,
JSON.stringify(latest500Data)
);
}, []);
const getMessagesMiddleware = async () => {
await new Promise((res) => {
setTimeout(() => {
res(null);
}, 400);
});
if (firstMount.current) return;
getMessages();
firstMount.current = true;
};
useEffect(() => {
if (currentThreadRef.current?.threadId !== currentThread?.threadId) {
firstMount.current = false;
}
if (currentThread && secretKey && !firstMount.current) {
getMessagesMiddleware();
// saveTimestamp(currentThread, user.name)
}
}, [currentThread, secretKey]);
const messageCallback = useCallback((msg: any) => {
// dispatch(addToHashMapMail(msg))
// setMessages((prev) => [msg, ...prev])
}, []);
const interval = useRef<any>(null);
const checkNewMessages = React.useCallback(
async (groupInfo: any) => {
try {
let threadId = groupInfo.threadId;
const identifier = `thmsg-${threadId}`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=20&includemetadata=false&offset=${0}&reverse=true&prefix=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
const latestMessage = messages[0];
if (!latestMessage) return;
const findMessage = responseData?.findIndex(
(item: any) => item?.identifier === latestMessage?.identifier
);
let sliceLength = responseData.length;
if (findMessage !== -1) {
sliceLength = findMessage;
}
const newArray = responseData.slice(0, findMessage).reverse();
let fullArrayMsg = [...messages];
for (const message of newArray) {
try {
const responseDataMessage = await getEncryptedResource({
identifier: message.identifier,
name: message.name,
secretKey: secretKeyRef.current,
});
const fullObject = {
...message,
...(responseDataMessage || {}),
id: message.identifier,
};
setHashMapMailMessages((prev) => {
return {
...prev,
[message.identifier]: fullObject,
};
});
const index = messages.findIndex(
(p) => p.identifier === fullObject.identifier
);
if (index !== -1) {
fullArrayMsg[index] = fullObject;
} else {
fullArrayMsg.unshift(fullObject);
}
} catch (error) {}
}
setMessages(fullArrayMsg);
} catch (error) {
} finally {
}
},
[messages]
);
// const checkNewMessagesFunc = useCallback(() => {
// let isCalling = false
// interval.current = setInterval(async () => {
// if (isCalling) return
// isCalling = true
// const res = await checkNewMessages(currentThread)
// isCalling = false
// }, 8000)
// }, [checkNewMessages, currentThrefirstMount.current = truead])
// useEffect(() => {
// checkNewMessagesFunc()
// return () => {
// if (interval?.current) {
// clearInterval(interval.current)
// }
// }
// }, [checkNewMessagesFunc])
const openNewPostWithQuote = useCallback((reply) => {
setPostReply(reply);
}, []);
const closeCallback = useCallback(() => {
setPostReply(null);
}, []);
const threadFetchModeFunc = (e) => {
const mode = e.detail?.mode;
if (mode === "last-page") {
getMailMessages(currentThread, null, null, true);
}
firstMount.current = true;
};
React.useEffect(() => {
subscribeToEvent("threadFetchMode", threadFetchModeFunc);
return () => {
unsubscribeFromEvent("threadFetchMode", threadFetchModeFunc);
};
}, []);
const combinedListTempAndReal = useMemo(() => {
// Combine the two lists
const combined = [...tempPublishedList, ...messages];
// Remove duplicates based on the "identifier"
const uniqueItems = new Map();
combined.forEach(item => {
uniqueItems.set(item.identifier, item); // This will overwrite duplicates, keeping the last occurrence
});
// Convert the map back to an array and sort by "created" timestamp in descending order
const sortedList = Array.from(uniqueItems.values()).sort((a, b) => a.created - b.created);
return sortedList;
}, [tempPublishedList, messages]);
if (!currentThread) return null;
return (
<GroupContainer
sx={{
position: "relative",
overflow: "auto",
width: "100%",
}}
>
<NewThread
groupInfo={groupInfo}
isMessage={true}
currentThread={currentThread}
messageCallback={messageCallback}
members={members}
userInfo={userInfo}
getSecretKey={getSecretKey}
closeCallback={closeCallback}
postReply={postReply}
myName={userInfo?.name}
publishCallback={setTempData}
/>
<ThreadContainerFullWidth>
<ThreadContainer >
<Spacer height="30px" />
<Box
sx={{
width: "100%",
alignItems: "center",
display: "flex",
justifyContent: "space-between",
}}
>
<GroupNameP>{currentThread?.threadData?.title}</GroupNameP>
<ShowMessageReturnButton
onClick={() => {
setMessages([]);
closeThread();
}}
>
<MailIconImg src={ReturnSVG} />
<ComposeP>Return to Threads</ComposeP>
</ShowMessageReturnButton>
</Box>
<Box
sx={{
width: "100%",
alignItems: "center",
display: "flex",
justifyContent: "center",
gap: "5px",
}}
>
<Button
onClick={() => {
getMailMessages(currentThread, null, null, false);
}}
disabled={!hasFirstPage}
variant="contained"
>
First Page
</Button>
<Button
onClick={() => {
getMailMessages(
currentThread,
messages[0].created,
null,
false
);
}}
disabled={!hasPreviousPage}
variant="contained"
>
Previous Page
</Button>
<Button
onClick={() => {
getMailMessages(
currentThread,
null,
messages[messages.length - 1].created,
false
);
}}
disabled={!hasNextPage}
variant="contained"
>
Next page
</Button>
<Button
onClick={() => {
getMailMessages(currentThread, null, null, true);
}}
disabled={!hasLastPage}
variant="contained"
>
Last page
</Button>
</Box>
<Spacer height="60px" />
{combinedListTempAndReal.map((message) => {
let fullMessage = message;
if (hashMapMailMessages[message?.identifier]) {
fullMessage = hashMapMailMessages[message.identifier];
return (
<ShowMessage
key={message?.identifier}
message={fullMessage}
openNewPostWithQuote={openNewPostWithQuote}
/>
);
} else if(message?.tempData){
return (
<ShowMessage
key={message?.identifier}
message={message?.tempData}
openNewPostWithQuote={openNewPostWithQuote}
/>
);
}
return (
<SingleThreadParent>
<Skeleton
variant="rectangular"
style={{
width: "100%",
height: 60,
borderRadius: "8px",
overflow: "hidden",
}}
/>
</SingleThreadParent>
);
})}
<div ref={containerRef} />
{!hasLastPage && !isLoading && (
<>
<Spacer height="20px" />
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "flex-end",
}}
>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => {
getMailMessages(currentThread, null, null, true);
}}
sx={{
color: "white",
}}
>
Refetch page
</Button>
</Box>
</>
)}
{messages?.length > 4 && (
<>
<Spacer height="30px" />
<Box
sx={{
width: "100%",
alignItems: "center",
display: "flex",
justifyContent: "center",
gap: "5px",
}}
>
<Button
onClick={() => {
getMailMessages(currentThread, null, null, false);
}}
disabled={!hasFirstPage}
variant="contained"
>
First Page
</Button>
<Button
onClick={() => {
getMailMessages(
currentThread,
messages[0].created,
null,
false
);
}}
disabled={!hasPreviousPage}
variant="contained"
>
Previous Page
</Button>
<Button
onClick={() => {
getMailMessages(
currentThread,
null,
messages[messages.length - 1].created,
false
);
}}
disabled={!hasNextPage}
variant="contained"
>
Next page
</Button>
<Button
onClick={() => {
getMailMessages(currentThread, null, null, true);
}}
disabled={!hasLastPage}
variant="contained"
>
Last page
</Button>
</Box>
<Spacer height="30px" />
</>
)}
</ThreadContainer>
</ThreadContainerFullWidth>
{/* {messages.length >= 20 && (
<LazyLoad onLoadMore={()=> getMailMessages(currentThread, false, true)}></LazyLoad>
)} */}
<LoadingSnackbar
open={isLoading}
info={{
message: "Loading posts... please wait.",
}}
/>
</GroupContainer>
);
};

View File

@ -0,0 +1,71 @@
.ql-editor {
min-height: 200px;
width: 100%;
color: black;
font-size: 16px;
font-family: Roboto;
max-height: 225px;
overflow-y: scroll;
padding: 0px !important;
}
.ql-editor::-webkit-scrollbar-track {
background-color: transparent;
cursor: default;
}
.ql-editor::-webkit-scrollbar-track:hover {
background-color: transparent;
}
.ql-editor::-webkit-scrollbar {
width: 16px;
height: 10px;
background-color: rgba(229, 229, 229, 0.70);
}
.ql-editor::-webkit-scrollbar-thumb {
background-color: #B0B0B0;
border-radius: 8px;
background-clip: content-box;
border: 4px solid transparent;
}
.ql-editor img {
cursor: default;
}
.ql-editor-display {
min-height: 20px;
width: 100%;
color: black;
font-size: 16px;
font-family: Roboto;
padding: 0px !important;
}
.ql-editor-display img {
cursor: default;
}
.ql-container {
font-size: 16px
}
.ql-toolbar .ql-stroke {
fill: none !important;
stroke: black !important;
}
.ql-toolbar .ql-fill {
fill: black !important;
stroke: none !important;
}
.ql-toolbar .ql-picker {
color: black !important;
}
.ql-toolbar .ql-picker-options {
background-color: white !important;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,114 @@
import * as React from "react";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Checkbox from "@mui/material/Checkbox";
import IconButton from "@mui/material/IconButton";
import CommentIcon from "@mui/icons-material/Comment";
import InfoIcon from "@mui/icons-material/Info";
import GroupAddIcon from '@mui/icons-material/GroupAdd';
import { executeEvent } from "../../utils/events";
import { Box, Typography } from "@mui/material";
import { Spacer } from "../../common/Spacer";
import { getGroupNames } from "./UserListOfInvites";
import { CustomLoader } from "../../common/CustomLoader";
import { getBaseApiReact } from "../../App";
export const GroupInvites = ({ myAddress, setOpenAddGroup }) => {
const [groupsWithJoinRequests, setGroupsWithJoinRequests] = React.useState([])
const [loading, setLoading] = React.useState(true)
const getJoinRequests = async ()=> {
try {
setLoading(true)
const response = await fetch(`${getBaseApiReact()}/groups/invites/${myAddress}/?limit=0`);
const data = await response.json();
const resMoreData = await getGroupNames(data)
setGroupsWithJoinRequests(resMoreData)
} catch (error) {
} finally {
setLoading(false)
}
}
React.useEffect(() => {
if (myAddress) {
getJoinRequests()
}
}, [myAddress]);
return (
<Box sx={{
width: '360px',
display: 'flex',
flexDirection: 'column',
bgcolor: "background.paper",
padding: '20px'
}}>
<Typography sx={{
fontSize: '14px'
}}>Group Invites</Typography>
<Spacer height="10px" />
{loading && groupsWithJoinRequests.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<CustomLoader />
</Box>
)}
{!loading && groupsWithJoinRequests.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<Typography sx={{
fontSize: '12px'
}}>No invites</Typography>
</Box>
)}
<List sx={{ width: "100%", maxWidth: 360, bgcolor: "background.paper", maxHeight: '300px', overflow: 'auto' }}>
{groupsWithJoinRequests?.map((group)=> {
return (
<ListItem
key={group?.groupId}
onClick={()=> {
setOpenAddGroup(true)
setTimeout(() => {
executeEvent("openGroupInvitesRequest", {});
}, 300);
}}
disablePadding
secondaryAction={
<IconButton edge="end" aria-label="comments">
<GroupAddIcon
sx={{
color: "white",
}}
/>
</IconButton>
}
>
<ListItemButton disableRipple role={undefined} dense>
<ListItemText primary={`${group?.groupName} has invited you`} />
</ListItemButton>
</ListItem>
)
})}
</List>
</Box>
);
};

View File

@ -0,0 +1,170 @@
import * as React from "react";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Checkbox from "@mui/material/Checkbox";
import IconButton from "@mui/material/IconButton";
import CommentIcon from "@mui/icons-material/Comment";
import InfoIcon from "@mui/icons-material/Info";
import { RequestQueueWithPromise } from "../../utils/queue/queue";
import GroupAddIcon from '@mui/icons-material/GroupAdd';
import { executeEvent } from "../../utils/events";
import { Box, Typography } from "@mui/material";
import { Spacer } from "../../common/Spacer";
import { CustomLoader } from "../../common/CustomLoader";
import { getBaseApi } from "../../background";
import { getBaseApiReact } from "../../App";
export const requestQueueGroupJoinRequests = new RequestQueueWithPromise(3)
export const GroupJoinRequests = ({ myAddress, groups, setOpenManageMembers, getTimestampEnterChat, setSelectedGroup, setGroupSection }) => {
const [groupsWithJoinRequests, setGroupsWithJoinRequests] = React.useState([])
const [loading, setLoading] = React.useState(true)
const getJoinRequests = async ()=> {
try {
setLoading(true)
let groupsAsAdmin = []
const getAllGroupsAsAdmin = groups.map(async (group)=> {
const isAdminResponse = await requestQueueGroupJoinRequests.enqueue(()=> {
return fetch(
`${getBaseApiReact()}/groups/members/${group.groupId}?limit=0&onlyAdmins=true`
);
})
const isAdminData = await isAdminResponse.json()
const findMyself = isAdminData?.members?.find((member)=> member.member === myAddress)
if(findMyself){
groupsAsAdmin.push(group)
}
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)=> {
const joinRequestResponse = await requestQueueGroupJoinRequests.enqueue(()=> {
return fetch(
`${getBaseApiReact()}/groups/joinrequests/${group.groupId}`
);
})
const joinRequestData = await joinRequestResponse.json()
return {
group,
data: joinRequestData
}
}))
setGroupsWithJoinRequests(res)
} catch (error) {
} finally {
setLoading(false)
}
}
React.useEffect(() => {
if (myAddress && groups.length > 0) {
getJoinRequests()
} else {
setLoading(false)
}
}, [myAddress, groups]);
return (
<Box sx={{
width: '360px',
display: 'flex',
flexDirection: 'column',
bgcolor: "background.paper",
padding: '20px'
}}>
<Typography sx={{
fontSize: '14px'
}}>Join Requests</Typography>
<Spacer height="10px" />
{loading && groupsWithJoinRequests.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<CustomLoader />
</Box>
)}
{!loading && groupsWithJoinRequests.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<Typography sx={{
fontSize: '12px'
}}>No join requests</Typography>
</Box>
)}
<List sx={{ width: "100%", maxWidth: 360, bgcolor: "background.paper", maxHeight: '300px', overflow: 'auto' }}>
{groupsWithJoinRequests?.map((group)=> {
if(group?.data?.length === 0) return null
return (
<ListItem
key={group?.groupId}
onClick={()=> {
setSelectedGroup(group?.group)
getTimestampEnterChat()
setGroupSection("announcement")
setOpenManageMembers(true)
setTimeout(() => {
executeEvent("openGroupJoinRequest", {});
}, 300);
}}
disablePadding
secondaryAction={
<IconButton edge="end" aria-label="comments">
<GroupAddIcon
sx={{
color: "white",
}}
/>
</IconButton>
}
>
<ListItemButton disableRipple role={undefined} dense>
<ListItemText primary={`${group?.group?.groupName} has ${group?.data?.length} pending join requests.`} />
</ListItemButton>
</ListItem>
)
})}
</List>
</Box>
);
};

View File

@ -0,0 +1,108 @@
import { LoadingButton } from "@mui/lab";
import {
Box,
Button,
Input,
MenuItem,
Select,
SelectChangeEvent,
} from "@mui/material";
import React, { useState } from "react";
import { Spacer } from "../../common/Spacer";
import { Label } from "./AddGroup";
import { getFee } from "../../background";
export const InviteMember = ({ groupId, setInfoSnack, setOpenSnack, show }) => {
const [value, setValue] = useState("");
const [expiryTime, setExpiryTime] = useState<string>('259200');
const [isLoadingInvite, setIsLoadingInvite] = useState(false)
const inviteMember = async () => {
try {
const fee = await getFee('GROUP_INVITE')
await show({
message: "Would you like to perform a GROUP_INVITE transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoadingInvite(true)
if (!expiryTime || !value) return;
new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "inviteToGroup",
payload: {
groupId,
qortalAddress: value,
inviteTime: +expiryTime,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message: `Successfully invited ${value}. It may take a couple of minutes for the changes to propagate`,
});
setOpenSnack(true);
res(response);
setValue("");
return
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
);
});
} catch (error) {} finally {
setIsLoadingInvite(false)
}
};
const handleChange = (event: SelectChangeEvent) => {
setExpiryTime(event.target.value as string);
};
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
Invite member
<Spacer height="20px" />
<Input
value={value}
placeholder="Name or address"
onChange={(e) => setValue(e.target.value)}
/>
<Spacer height="20px" />
<Label>Invitation Expiry Time</Label>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={expiryTime}
label="Invitation Expiry Time"
onChange={handleChange}
>
<MenuItem value={10800}>3 hours</MenuItem>
<MenuItem value={21600}>6 hours</MenuItem>
<MenuItem value={43200}>12 hours</MenuItem>
<MenuItem value={86400}>1 day</MenuItem>
<MenuItem value={259200}>3 days</MenuItem>
<MenuItem value={432000}>5 days</MenuItem>
<MenuItem value={604800}>7 days</MenuItem>
<MenuItem value={864000}>10 days</MenuItem>
<MenuItem value={1296000}>15 days</MenuItem>
<MenuItem value={2592000}>30 days</MenuItem>
</Select>
<Spacer height="20px" />
<LoadingButton variant="contained" loadingPosition="start" loading={isLoadingInvite} onClick={inviteMember}>Invite</LoadingButton>
</Box>
);
};

View File

@ -0,0 +1,188 @@
import React, { useEffect, useRef, useState } from 'react';
import { Avatar, Box, Button, ListItem, ListItemAvatar, ListItemButton, ListItemText, Popover } from '@mui/material';
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import { getNameInfo } from './Group';
import { getBaseApi, getFee } from '../../background';
import { LoadingButton } from '@mui/lab';
import { getBaseApiReact } from '../../App';
export const getMemberInvites = async (groupNumber) => {
const response = await fetch(`${getBaseApiReact()}/groups/bans/${groupNumber}?limit=0`);
const groupData = await response.json();
return groupData;
}
const getNames = async (listOfMembers) => {
let members = [];
if (listOfMembers && Array.isArray(listOfMembers)) {
for (const member of listOfMembers) {
if (member.offender) {
const name = await getNameInfo(member.offender);
if (name) {
members.push({ ...member, name });
}
}
}
}
return members;
}
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const ListOfBans = ({ groupId, setInfoSnack, setOpenSnack, show }) => {
const [bans, setBans] = useState([]);
const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to
const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Track which list item has the popover open
const listRef = useRef();
const [isLoadingUnban, setIsLoadingUnban] = useState(false);
const getInvites = async (groupId) => {
try {
const res = await getMemberInvites(groupId);
const resWithNames = await getNames(res);
setBans(resWithNames);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
if (groupId) {
getInvites(groupId);
}
}, [groupId]);
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const handleCancelBan = async (address)=> {
try {
const fee = await getFee('CANCEL_GROUP_BAN')
await show({
message: "Would you like to perform a CANCEL_GROUP_BAN transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoadingUnban(true)
new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "cancelBan", payload: {
groupId,
qortalAddress: address,
}}, (response) => {
if (!response?.error) {
res(response)
setIsLoadingUnban(false)
setInfoSnack({
type: "success",
message: "Successfully unbanned user. It may take a couple of minutes for the changes to propagate",
});
handlePopoverClose();
setOpenSnack(true);
return
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error)
});
})
} catch (error) {
} finally {
setIsLoadingUnban(false)
}
}
const rowRenderer = ({ index, key, parent, style }) => {
const member = bans[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style} onLoad={measure}>
<ListItem disablePadding>
<Popover
open={openPopoverIndex === index}
anchorEl={popoverAnchor}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
style={{ marginTop: "8px" }}
>
<Box
sx={{
width: "325px",
height: "250px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
padding: "10px",
}}
>
<LoadingButton loading={isLoadingUnban}
loadingPosition="start"
variant="contained" onClick={()=> handleCancelBan(member?.offender)}>Cancel Ban</LoadingButton>
</Box>
</Popover>
<ListItemButton onClick={(event) => handlePopoverOpen(event, index)}>
<ListItemAvatar>
<Avatar
alt={member?.name}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${member?.name}/qortal_avatar?async=true`}
/>
</ListItemAvatar>
<ListItemText primary={member?.name || member?.offender} />
</ListItemButton>
</ListItem>
</div>
)}
</CellMeasurer>
);
};
return (
<div>
<p>Ban list</p>
<div style={{ position: 'relative', height: '500px', width: '600px', display: 'flex', flexDirection: 'column', flexShrink: 1 }}>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={bans.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</div>
);
}

View File

@ -0,0 +1,189 @@
import React, { useEffect, useRef, useState } from 'react';
import { Avatar, Box, Button, ListItem, ListItemAvatar, ListItemButton, ListItemText, Popover } from '@mui/material';
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import { getNameInfo } from './Group';
import { getBaseApi, getFee } from '../../background';
import { LoadingButton } from '@mui/lab';
import { getBaseApiReact } from '../../App';
export const getMemberInvites = async (groupNumber) => {
const response = await fetch(`${getBaseApiReact()}/groups/invites/group/${groupNumber}?limit=0`);
const groupData = await response.json();
return groupData;
}
const getNames = async (listOfMembers) => {
let members = [];
if (listOfMembers && Array.isArray(listOfMembers)) {
for (const member of listOfMembers) {
if (member.invitee) {
const name = await getNameInfo(member.invitee);
if (name) {
members.push({ ...member, name });
}
}
}
}
return members;
}
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const ListOfInvites = ({ groupId, setInfoSnack, setOpenSnack, show }) => {
const [invites, setInvites] = useState([]);
const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to
const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Track which list item has the popover open
const [isLoadingCancelInvite, setIsLoadingCancelInvite] = useState(false);
const listRef = useRef();
const getInvites = async (groupId) => {
try {
const res = await getMemberInvites(groupId);
const resWithNames = await getNames(res);
setInvites(resWithNames);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
if (groupId) {
getInvites(groupId);
}
}, [groupId]);
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const handleCancelInvitation = async (address)=> {
try {
const fee = await getFee('CANCEL_GROUP_INVITE')
await show({
message: "Would you like to perform a CANCEL_GROUP_INVITE transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoadingCancelInvite(true)
await new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "cancelInvitationToGroup", payload: {
groupId,
qortalAddress: address,
}}, (response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message: "Successfully canceled invitation. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
setIsLoadingCancelInvite(true)
res(response)
return
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error)
});
})
} catch (error) {
} finally {
setIsLoadingCancelInvite(false)
}
}
const rowRenderer = ({ index, key, parent, style }) => {
const member = invites[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style} onLoad={measure}>
<ListItem disablePadding>
<Popover
open={openPopoverIndex === index}
anchorEl={popoverAnchor}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
style={{ marginTop: "8px" }}
>
<Box
sx={{
width: "325px",
height: "250px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
padding: "10px",
}}
>
<LoadingButton loading={isLoadingCancelInvite}
loadingPosition="start"
variant="contained" onClick={()=> handleCancelInvitation(member?.invitee)}>Cancel Invitation</LoadingButton>
</Box>
</Popover>
<ListItemButton onClick={(event) => handlePopoverOpen(event, index)}>
<ListItemAvatar>
<Avatar
alt={member?.name}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${member?.name}/qortal_avatar?async=true`}
/>
</ListItemAvatar>
<ListItemText primary={member?.name || member?.invitee} />
</ListItemButton>
</ListItem>
</div>
)}
</CellMeasurer>
);
};
return (
<div>
<p>Invitees list</p>
<div style={{ position: 'relative', height: '500px', width: '600px', display: 'flex', flexDirection: 'column', flexShrink: 1 }}>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={invites.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</div>
);
}

View File

@ -0,0 +1,189 @@
import React, { useEffect, useRef, useState } from 'react';
import { Avatar, Box, Button, ListItem, ListItemAvatar, ListItemButton, ListItemText, Popover } from '@mui/material';
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import { getNameInfo } from './Group';
import { getBaseApi, getFee } from '../../background';
import { LoadingButton } from '@mui/lab';
import { getBaseApiReact } from '../../App';
export const getMemberInvites = async (groupNumber) => {
const response = await fetch(`${getBaseApiReact()}/groups/joinrequests/${groupNumber}?limit=0`);
const groupData = await response.json();
return groupData;
}
const getNames = async (listOfMembers) => {
let members = [];
if (listOfMembers && Array.isArray(listOfMembers)) {
for (const member of listOfMembers) {
if (member.joiner) {
const name = await getNameInfo(member.joiner);
if (name) {
members.push({ ...member, name });
}
}
}
}
return members;
}
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const ListOfJoinRequests = ({ groupId, setInfoSnack, setOpenSnack, show }) => {
const [invites, setInvites] = useState([]);
const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to
const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Track which list item has the popover open
const listRef = useRef();
const [isLoadingAccept, setIsLoadingAccept] = useState(false);
const getInvites = async (groupId) => {
try {
const res = await getMemberInvites(groupId);
const resWithNames = await getNames(res);
setInvites(resWithNames);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
if (groupId) {
getInvites(groupId);
}
}, [groupId]);
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const handleAcceptJoinRequest = async (address)=> {
try {
const fee = await getFee('GROUP_INVITE')
await show({
message: "Would you like to perform a GROUP_INVITE transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoadingAccept(true)
await new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "inviteToGroup", payload: {
groupId,
qortalAddress: address,
inviteTime: 10800,
}}, (response) => {
if (!response?.error) {
setIsLoadingAccept(false)
setInfoSnack({
type: "success",
message: "Successfully accepted join request. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
res(response)
return
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error)
});
})
} catch (error) {
} finally {
setIsLoadingAccept(false)
}
}
const rowRenderer = ({ index, key, parent, style }) => {
const member = invites[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style} onLoad={measure}>
<ListItem disablePadding>
<Popover
open={openPopoverIndex === index}
anchorEl={popoverAnchor}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
style={{ marginTop: "8px" }}
>
<Box
sx={{
width: "325px",
height: "250px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
padding: "10px",
}}
>
<LoadingButton loading={isLoadingAccept}
loadingPosition="start"
variant="contained" onClick={()=> handleAcceptJoinRequest(member?.joiner)}>Accept</LoadingButton>
</Box>
</Popover>
<ListItemButton onClick={(event) => handlePopoverOpen(event, index)}>
<ListItemAvatar>
<Avatar
alt={member?.name}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${member?.name}/qortal_avatar?async=true`}
/>
</ListItemAvatar>
<ListItemText primary={member?.name || member?.joiner} />
</ListItemButton>
</ListItem>
</div>
)}
</CellMeasurer>
);
};
return (
<div>
<p>Join request list</p>
<div style={{ position: 'relative', height: '500px', width: '600px', display: 'flex', flexDirection: 'column', flexShrink: 1 }}>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={invites.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</div>
);
}

View File

@ -0,0 +1,385 @@
import {
Avatar,
Box,
Button,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemText,
Popover,
Typography,
} from "@mui/material";
import React, { useRef, useState } from "react";
import {
AutoSizer,
CellMeasurer,
CellMeasurerCache,
List,
} from "react-virtualized";
import { LoadingButton } from "@mui/lab";
import { getBaseApi, getFee } from "../../background";
import { getBaseApiReact } from "../../App";
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
const ListOfMembers = ({
members,
groupId,
setInfoSnack,
setOpenSnack,
isAdmin,
isOwner,
show,
}) => {
const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to
const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Track which list item has the popover open
const [isLoadingKick, setIsLoadingKick] = useState(false);
const [isLoadingBan, setIsLoadingBan] = useState(false);
const [isLoadingMakeAdmin, setIsLoadingMakeAdmin] = useState(false);
const [isLoadingRemoveAdmin, setIsLoadingRemoveAdmin] = useState(false);
const listRef = useRef();
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const handleKick = async (address) => {
try {
const fee = await getFee("GROUP_KICK");
await show({
message: "Would you like to perform a GROUP_KICK transaction?",
publishFee: fee.fee + " QORT",
});
setIsLoadingKick(true);
new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "kickFromGroup",
payload: {
groupId,
qortalAddress: address,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message:
"Successfully kicked member from group. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
res(response);
return;
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
);
});
} catch (error) {
} finally {
setIsLoadingKick(false);
}
};
const handleBan = async (address) => {
try {
const fee = await getFee("GROUP_BAN");
await show({
message: "Would you like to perform a GROUP_BAN transaction?",
publishFee: fee.fee + " QORT",
});
setIsLoadingBan(true);
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "banFromGroup",
payload: {
groupId,
qortalAddress: address,
rBanTime: 0,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message:
"Successfully banned member from group. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
res(response);
return;
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
);
});
} catch (error) {
} finally {
setIsLoadingBan(false);
}
};
const makeAdmin = async (address) => {
try {
const fee = await getFee("ADD_GROUP_ADMIN");
await show({
message: "Would you like to perform a ADD_GROUP_ADMIN transaction?",
publishFee: fee.fee + " QORT",
});
setIsLoadingMakeAdmin(true);
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "makeAdmin",
payload: {
groupId,
qortalAddress: address,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message:
"Successfully made member an admin. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
res(response);
return;
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
);
});
} catch (error) {
} finally {
setIsLoadingMakeAdmin(false);
}
};
const removeAdmin = async (address) => {
try {
const fee = await getFee("REMOVE_GROUP_ADMIN");
await show({
message: "Would you like to perform a REMOVE_GROUP_ADMIN transaction?",
publishFee: fee.fee + " QORT",
});
setIsLoadingRemoveAdmin(true);
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "removeAdmin",
payload: {
groupId,
qortalAddress: address,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message:
"Successfully removed member as an admin. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
res(response);
return;
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
);
});
} catch (error) {
} finally {
setIsLoadingRemoveAdmin(false);
}
};
const rowRenderer = ({ index, key, parent, style }) => {
const member = members[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style} onLoad={measure}>
<Popover
open={openPopoverIndex === index}
anchorEl={popoverAnchor}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
style={{ marginTop: "8px" }}
>
<Box
sx={{
width: "325px",
height: "250px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
padding: "10px",
}}
>
{isOwner && (
<>
<LoadingButton
loading={isLoadingKick}
loadingPosition="start"
variant="contained"
onClick={() => handleKick(member?.member)}
>
Kick member from group
</LoadingButton>
<LoadingButton
loading={isLoadingBan}
loadingPosition="start"
variant="contained"
onClick={() => handleBan(member?.member)}
>
Ban member from group
</LoadingButton>
<LoadingButton
loading={isLoadingMakeAdmin}
loadingPosition="start"
variant="contained"
onClick={() => makeAdmin(member?.member)}
>
Make an admin
</LoadingButton>
<LoadingButton
loading={isLoadingRemoveAdmin}
loadingPosition="start"
variant="contained"
onClick={() => removeAdmin(member?.member)}
>
Remove as admin
</LoadingButton>
</>
)}
</Box>
</Popover>
<ListItem
key={member?.member}
// secondaryAction={
// <Checkbox
// edge="end"
// onChange={handleToggle(value)}
// checked={checked.indexOf(value) !== -1}
// inputProps={{ 'aria-labelledby': labelId }}
// />
// }
disablePadding
>
<ListItemButton
onClick={(event) => handlePopoverOpen(event, index)}
>
<ListItemAvatar>
<Avatar
alt={member?.name || member?.member}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${member?.name}/qortal_avatar?async=true`}
/>
</ListItemAvatar>
<ListItemText
id={""}
primary={member?.name || member?.member}
/>
{member?.isAdmin && (
<Typography sx={{
color: 'white',
marginLeft: 'auto'
}}>Admin</Typography>
)}
</ListItemButton>
</ListItem>
</div>
)}
</CellMeasurer>
);
};
return (
<div>
<p>Member list</p>
<div
style={{
position: "relative",
height: "500px",
width: "600px",
display: "flex",
flexDirection: "column",
flexShrink: 1,
}}
>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={members.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
// onScroll={handleScroll}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</div>
);
};
export default ListOfMembers;

View File

@ -0,0 +1,138 @@
import * as React from "react";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Checkbox from "@mui/material/Checkbox";
import IconButton from "@mui/material/IconButton";
import CommentIcon from "@mui/icons-material/Comment";
import InfoIcon from "@mui/icons-material/Info";
import GroupAddIcon from '@mui/icons-material/GroupAdd';
import { executeEvent } from "../../utils/events";
import { Box, Typography } from "@mui/material";
import { Spacer } from "../../common/Spacer";
import { getGroupNames } from "./UserListOfInvites";
import { CustomLoader } from "../../common/CustomLoader";
import VisibilityIcon from '@mui/icons-material/Visibility';
export const ListOfThreadPostsWatched = () => {
const [posts, setPosts] = React.useState([])
const [loading, setLoading] = React.useState(true)
const getPosts = async ()=> {
try {
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "getThreadActivity",
payload: {
},
},
(response) => {
if (!response?.error) {
if(!response) {
res(null)
return
}
const uniquePosts = response.reduce((acc, current) => {
const x = acc.find(item => item?.thread?.threadId === current?.thread?.threadId);
if (!x) {
return acc.concat([current]);
} else {
return acc;
}
}, []);
setPosts(uniquePosts)
res(uniquePosts);
return
}
rej(response.error);
}
);
});
} catch (error) {
} finally {
setLoading(false)
}
}
React.useEffect(() => {
getPosts()
}, []);
return (
<Box sx={{
width: '360px',
display: 'flex',
flexDirection: 'column',
bgcolor: "background.paper",
padding: '20px'
}}>
<Typography sx={{
fontSize: '14px'
}}>New Thread Posts</Typography>
<Spacer height="10px" />
{loading && posts.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<CustomLoader />
</Box>
)}
{!loading && posts.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<Typography sx={{
fontSize: '12px'
}}>No thread post notifications</Typography>
</Box>
)}
<List sx={{ width: "100%", maxWidth: 360, bgcolor: "background.paper", maxHeight: '300px', overflow: 'auto' }}>
{posts?.map((post)=> {
return (
<ListItem
key={post?.thread?.threadId}
onClick={()=> {
executeEvent("openThreadNewPost", {
data: post
});
}}
disablePadding
secondaryAction={
<IconButton edge="end" aria-label="comments">
<VisibilityIcon
sx={{
color: "red",
}}
/>
</IconButton>
}
>
<ListItemButton disableRipple role={undefined} dense>
<ListItemText primary={`New post in ${post?.thread?.threadData?.title}`} />
</ListItemButton>
</ListItem>
)
})}
</List>
</Box>
);
};

View File

@ -0,0 +1,316 @@
import * as React from "react";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import ListItemText from "@mui/material/ListItemText";
import ListItemButton from "@mui/material/ListItemButton";
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import CloseIcon from "@mui/icons-material/Close";
import Slide from "@mui/material/Slide";
import { TransitionProps } from "@mui/material/transitions";
import ListOfMembers from "./ListOfMembers";
import { InviteMember } from "./InviteMember";
import { ListOfInvites } from "./ListOfInvites";
import { ListOfBans } from "./ListOfBans";
import { ListOfJoinRequests } from "./ListOfJoinRequests";
import { Box, Tab, Tabs } from "@mui/material";
import { CustomizedSnackbars } from "../Snackbar/Snackbar";
import { MyContext } from "../../App";
import { getGroupMembers, getNames } from "./Group";
import { LoadingSnackbar } from "../Snackbar/LoadingSnackbar";
import { getFee } from "../../background";
import { LoadingButton } from "@mui/lab";
import { subscribeToEvent, unsubscribeFromEvent } from "../../utils/events";
function a11yProps(index: number) {
return {
id: `simple-tab-${index}`,
"aria-controls": `simple-tabpanel-${index}`,
};
}
const Transition = React.forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement;
},
ref: React.Ref<unknown>
) {
return <Slide direction="up" ref={ref} {...props} />;
});
export const ManageMembers = ({
address,
open,
setOpen,
selectedGroup,
isAdmin,
isOwner
}) => {
const [membersWithNames, setMembersWithNames] = React.useState([]);
const [tab, setTab] = React.useState("create");
const [value, setValue] = React.useState(0);
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const [isLoadingMembers, setIsLoadingMembers] = React.useState(false)
const [isLoadingLeave, setIsLoadingLeave] = React.useState(false)
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
const { show, setTxList } = React.useContext(MyContext);
const handleClose = () => {
setOpen(false);
};
const handleLeaveGroup = async () => {
try {
setIsLoadingLeave(true)
const fee = await getFee('LEAVE_GROUP')
await show({
message: "Would you like to perform an LEAVE_GROUP transaction?" ,
publishFee: fee.fee + ' QORT'
})
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "leaveGroup",
payload: {
groupId: selectedGroup?.groupId,
},
},
(response) => {
if (!response?.error) {
setTxList((prev)=> [{
...response,
type: 'leave-group',
label: `Left Group ${selectedGroup?.groupName}: awaiting confirmation`,
labelDone: `Left Group ${selectedGroup?.groupName}: success !`,
done: false,
groupId: selectedGroup?.groupId,
}, ...prev])
res(response);
setInfoSnack({
type: "success",
message: "Successfully requested to leave group. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
return
}
rej(response.error);
}
);
});
} catch (error) {} finally {
setIsLoadingLeave(false)
}
};
const getMembers = async (groupId) => {
try {
setIsLoadingMembers(true)
const res = await getGroupMembers(groupId);
const resWithNames = await getNames(res.members);
setMembersWithNames(resWithNames);
setIsLoadingMembers(false)
} catch (error) {}
};
React.useEffect(()=> {
if(selectedGroup?.groupId){
getMembers(selectedGroup?.groupId)
}
}, [selectedGroup?.groupId])
const openGroupJoinRequestFunc = ()=> {
setValue(4)
}
React.useEffect(() => {
subscribeToEvent("openGroupJoinRequest", openGroupJoinRequestFunc);
return () => {
unsubscribeFromEvent("openGroupJoinRequest", openGroupJoinRequestFunc);
};
}, []);
return (
<React.Fragment>
<Dialog
fullScreen
open={open}
onClose={handleClose}
TransitionComponent={Transition}
>
<AppBar sx={{ position: "relative", bgcolor: "#232428" }}>
<Toolbar>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
Manage Members
</Typography>
<IconButton
edge="start"
color="inherit"
onClick={handleClose}
aria-label="close"
>
<CloseIcon />
</IconButton>
</Toolbar>
</AppBar>
<Box
sx={{
bgcolor: "#27282c",
flexGrow: 1,
overflowY: "auto",
color: "white",
}}
>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs
sx={{
"& .MuiTabs-indicator": {
backgroundColor: "white",
},
}}
value={value}
onChange={handleChange}
aria-label="basic tabs example"
>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="List of members"
{...a11yProps(0)}
/>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="Invite new member"
{...a11yProps(1)}
/>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="List of invites"
{...a11yProps(2)}
/>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="List of bans"
{...a11yProps(3)}
/>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="Join requests"
{...a11yProps(4)}
/>
</Tabs>
</Box>
{selectedGroup?.groupId && !isOwner && (
<LoadingButton loading={isLoadingLeave} loadingPosition="start"
variant="contained" onClick={handleLeaveGroup}>
Leave Group
</LoadingButton>
)}
{value === 0 && (
<Box
sx={{
width: "100%",
padding: "25px",
}}
>
<ListOfMembers
members={membersWithNames || []}
groupId={selectedGroup?.groupId}
setOpenSnack={setOpenSnack}
setInfoSnack={setInfoSnack}
isAdmin={isAdmin}
isOwner={isOwner}
show={show}
/>
</Box>
)}
{value === 1 && (
<Box
sx={{
width: "100%",
padding: "25px",
}}
>
<InviteMember show={show} groupId={selectedGroup?.groupId} setOpenSnack={setOpenSnack} setInfoSnack={setInfoSnack} />
</Box>
)}
{value === 2 && (
<Box
sx={{
width: "100%",
padding: "25px",
}}
>
<ListOfInvites show={show} groupId={selectedGroup?.groupId} setOpenSnack={setOpenSnack} setInfoSnack={setInfoSnack} />
</Box>
)}
{value === 3 && (
<Box
sx={{
width: "100%",
padding: "25px",
}}
>
<ListOfBans show={show} groupId={selectedGroup?.groupId} setOpenSnack={setOpenSnack} setInfoSnack={setInfoSnack} />
</Box>
)}
{value === 4 && (
<Box
sx={{
width: "100%",
padding: "25px",
}}
>
<ListOfJoinRequests show={show} setOpenSnack={setOpenSnack} setInfoSnack={setInfoSnack} groupId={selectedGroup?.groupId} />
</Box>
)}
</Box>
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
<LoadingSnackbar
open={isLoadingMembers}
info={{
message: "Loading member list with names... please wait.",
}}
/>
</Dialog>
</React.Fragment>
);
};

View File

@ -0,0 +1,166 @@
import * as React from "react";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Checkbox from "@mui/material/Checkbox";
import IconButton from "@mui/material/IconButton";
import CommentIcon from "@mui/icons-material/Comment";
import InfoIcon from "@mui/icons-material/Info";
import { Box, Typography } from "@mui/material";
import { Spacer } from "../../common/Spacer";
export const ThingsToDoInitial = ({ myAddress, name, hasGroups, balance }) => {
const [checked1, setChecked1] = React.useState(false);
const [checked2, setChecked2] = React.useState(false);
const [checked3, setChecked3] = React.useState(false);
// const getAddressInfo = async (address) => {
// const response = await fetch(getBaseApiReact() + "/addresses/" + address);
// const data = await response.json();
// if (data.error && data.error === 124) {
// setChecked1(false);
// } else if (data.address) {
// setChecked1(true);
// }
// };
// const checkInfo = async () => {
// try {
// getAddressInfo(myAddress);
// } catch (error) {}
// };
React.useEffect(() => {
if (balance && +balance >= 6) {
setChecked1(true)
}
}, [balance]);
React.useEffect(()=> {
if(hasGroups) setChecked3(true)
}, [hasGroups])
React.useEffect(()=> {
if(name) setChecked2(true)
}, [name])
return (
<Box sx={{
width: '360px',
display: 'flex',
flexDirection: 'column',
bgcolor: "background.paper",
padding: '20px'
}}>
<Typography sx={{
fontSize: '14px'
}}>Suggestion: Complete the following</Typography>
<Spacer height="10px" />
<List sx={{ width: "100%", maxWidth: 360 }}>
<ListItem
// secondaryAction={
// <IconButton edge="end" aria-label="comments">
// <InfoIcon
// sx={{
// color: "white",
// }}
// />
// </IconButton>
// }
disablePadding
>
<ListItemButton disableRipple role={undefined} dense>
<ListItemIcon>
<Checkbox
edge="start"
checked={checked1}
tabIndex={-1}
disableRipple
disabled={true}
sx={{
"&.Mui-checked": {
color: "white", // Customize the color when checked
},
"& .MuiSvgIcon-root": {
color: "white",
},
}}
/>
</ListItemIcon>
<ListItemText primary={`Have at least 6 QORT in your wallet`} />
</ListItemButton>
</ListItem>
<ListItem
// secondaryAction={
// <IconButton edge="end" aria-label="comments">
// <InfoIcon
// sx={{
// color: "white",
// }}
// />
// </IconButton>
// }
disablePadding
>
<ListItemButton disableRipple role={undefined} dense>
<ListItemIcon>
<Checkbox
edge="start"
checked={checked2}
tabIndex={-1}
disableRipple
disabled={true}
sx={{
"&.Mui-checked": {
color: "white", // Customize the color when checked
},
"& .MuiSvgIcon-root": {
color: "white",
},
}}
/>
</ListItemIcon>
<ListItemText primary={`Register a name`} />
</ListItemButton>
</ListItem>
<ListItem
// secondaryAction={
// <IconButton edge="end" aria-label="comments">
// <InfoIcon
// sx={{
// color: "white",
// }}
// />
// </IconButton>
// }
disablePadding
>
<ListItemButton disableRipple role={undefined} dense>
<ListItemIcon>
<Checkbox
edge="start"
checked={checked3}
tabIndex={-1}
disableRipple
disabled={true}
sx={{
"&.Mui-checked": {
color: "white", // Customize the color when checked
},
"& .MuiSvgIcon-root": {
color: "white",
},
}}
/>
</ListItemIcon>
<ListItemText primary={`Join a group`} />
</ListItemButton>
</ListItem>
</List>
</Box>
);
};

View File

@ -0,0 +1,206 @@
import { Box, Button, ListItem, ListItemButton, ListItemText, Popover, Typography } from '@mui/material';
import React, { useContext, useEffect, useRef, useState } from 'react'
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import { MyContext, getBaseApiReact } from '../../App';
import { LoadingButton } from '@mui/lab';
import { getBaseApi, getFee } from '../../background';
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
const getGroupInfo = async (groupId)=> {
const response = await fetch(`${getBaseApiReact()}/groups/` + groupId);
const groupData = await response.json();
if (groupData) {
return groupData
}
}
export const getGroupNames = async (listOfGroups) => {
let groups = [];
if (listOfGroups && Array.isArray(listOfGroups)) {
for (const group of listOfGroups) {
const groupInfo = await getGroupInfo(group.groupId);
if (groupInfo) {
groups.push({ ...group, ...groupInfo });
}
}
}
return groups;
}
export const UserListOfInvites = ({myAddress, setInfoSnack, setOpenSnack}) => {
const {txList, setTxList, show} = useContext(MyContext)
const [invites, setInvites] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to
const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Track which list item has the popover open
const listRef = useRef();
const getRequests = async () => {
try {
const response = await fetch(`${getBaseApiReact()}/groups/invites/${myAddress}/?limit=0`);
const inviteData = await response.json();
const resMoreData = await getGroupNames(inviteData)
setInvites(resMoreData);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
getRequests();
}, []);
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const handleJoinGroup = async (groupId, groupName)=> {
try {
const fee = await getFee('JOIN_GROUP')
await show({
message: "Would you like to perform an JOIN_GROUP transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoading(true);
await new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "joinGroup", payload: {
groupId,
}}, (response) => {
if (!response?.error) {
setTxList((prev)=> [{
...response,
type: 'joined-group',
label: `Joined Group ${groupName}: awaiting confirmation`,
labelDone: `Joined Group ${groupName}: success !`,
done: false,
groupId,
}, ...prev])
res(response)
setInfoSnack({
type: "success",
message: "Successfully requested to join group. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
return
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error)
});
})
} catch (error) {
} finally {
setIsLoading(false);
}
}
const rowRenderer = ({ index, key, parent, style }) => {
const invite = invites[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style} onLoad={measure}>
<ListItem disablePadding>
<Popover
open={openPopoverIndex === index}
anchorEl={popoverAnchor}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
style={{ marginTop: "8px" }}
>
<Box
sx={{
width: "325px",
height: "250px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
padding: "10px",
}}
>
<Typography>Join {invite?.groupName}</Typography>
<LoadingButton
loading={isLoading}
loadingPosition="start"
variant="contained" onClick={()=> handleJoinGroup(invite?.groupId, invite?.groupName)}>Join group</LoadingButton>
</Box>
</Popover>
<ListItemButton onClick={(event) => handlePopoverOpen(event, index)}>
<ListItemText primary={invite?.groupName} secondary={invite?.description} />
</ListItemButton>
</ListItem>
</div>
)}
</CellMeasurer>
);
};
return (
<div>
<p>Invite list</p>
<div style={{ position: 'relative', height: '500px', width: '600px', display: 'flex', flexDirection: 'column', flexShrink: 1 }}>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={invites.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</div>
);
}

View File

@ -0,0 +1,109 @@
import React, { useEffect, useRef } from 'react';
import { getBaseApiReactSocket } from '../../App';
export const WebSocketActive = ({ myAddress }) => {
const socketRef = useRef(null); // WebSocket reference
const timeoutIdRef = useRef(null); // Timeout ID reference
const groupSocketTimeoutRef = useRef(null); // Group Socket Timeout reference
const forceCloseWebSocket = () => {
if (socketRef.current) {
console.log('Force closing the WebSocket');
clearTimeout(timeoutIdRef.current);
clearTimeout(groupSocketTimeoutRef.current);
socketRef.current.close(1000, 'forced');
socketRef.current = null;
}
};
useEffect(() => {
if (!myAddress) return; // Only proceed if myAddress is set
if (!window?.location?.href?.includes("?main=true")) return;
const pingHeads = () => {
try {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send('ping');
timeoutIdRef.current = setTimeout(() => {
if (socketRef.current) {
socketRef.current.close();
clearTimeout(groupSocketTimeoutRef.current);
}
}, 5000); // Close if no pong in 5 seconds
}
} catch (error) {
console.error('Error during ping:', error);
}
};
const initWebsocketMessageGroup = async () => {
forceCloseWebSocket(); // Ensure we close any existing connection
const currentAddress = myAddress;
try {
const socketLink = `${getBaseApiReactSocket()}/websockets/chat/active/${currentAddress}?encoding=BASE64`;
socketRef.current = new WebSocket(socketLink);
socketRef.current.onopen = () => {
console.log('WebSocket connection opened');
setTimeout(pingHeads, 50); // Initial ping
};
socketRef.current.onmessage = (e) => {
try {
if (e.data === 'pong') {
clearTimeout(timeoutIdRef.current);
groupSocketTimeoutRef.current = setTimeout(pingHeads, 45000); // Ping every 45 seconds
} else {
const data = JSON.parse(e.data);
const filteredGroups = data.groups?.filter(item => item?.groupId !== 0) || [];
const sortedGroups = filteredGroups.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
const sortedDirects = (data?.direct || []).filter(item =>
item?.name !== 'extension-proxy' && item?.address !== 'QSMMGSgysEuqDCuLw3S4cHrQkBrh3vP3VH'
).sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
chrome.runtime.sendMessage({
action: 'handleActiveGroupDataFromSocket',
payload: {
groups: sortedGroups,
directs: sortedDirects,
},
});
}
} catch (error) {
console.error('Error parsing onmessage data:', error);
}
};
socketRef.current.onclose = (event) => {
clearTimeout(groupSocketTimeoutRef.current);
clearTimeout(timeoutIdRef.current);
console.warn(`WebSocket closed: ${event.reason || 'unknown reason'}`);
if (event.reason !== 'forced' && event.code !== 1000) {
setTimeout(() => initWebsocketMessageGroup(), 10000); // Retry after 10 seconds
}
};
socketRef.current.onerror = (error) => {
console.error('WebSocket error:', error);
clearTimeout(groupSocketTimeoutRef.current);
clearTimeout(timeoutIdRef.current);
if (socketRef.current) {
socketRef.current.close();
}
};
} catch (error) {
console.error('Error initializing WebSocket:', error);
}
};
initWebsocketMessageGroup(); // Initialize WebSocket on component mount
return () => {
forceCloseWebSocket(); // Clean up WebSocket on component unmount
};
}, [myAddress]);
return null;
};

View File

@ -0,0 +1,21 @@
import * as React from 'react';
import Button from '@mui/material/Button';
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
export const LoadingSnackbar = ({open, info}) => {
return (
<div>
<Snackbar anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} open={open}>
<Alert
severity="info"
variant="filled"
sx={{ width: '100%' }}
>
{info?.message}
</Alert>
</Snackbar>
</div>
);
}

View File

@ -0,0 +1,38 @@
import * as React from 'react';
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}) => {
const handleClose = (
event?: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason,
) => {
if (reason === 'clickaway') {
return;
}
setOpen(false);
setInfo(null)
};
return (
<div>
<Snackbar anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} open={open} autoHideDuration={6000} onClose={handleClose}>
<Alert
onClose={handleClose}
severity={info?.type}
variant="filled"
sx={{ width: '100%' }}
>
{info?.message}
</Alert>
</Snackbar>
</div>
);
}

View File

@ -0,0 +1,175 @@
import { List, ListItemButton, ListItemIcon } from "@mui/material";
import React, { useContext, useEffect, useRef } from "react";
import ListItemText from "@mui/material/ListItemText";
import Collapse from "@mui/material/Collapse";
import InboxIcon from "@mui/icons-material/MoveToInbox";
import ExpandLess from "@mui/icons-material/ExpandLess";
import ExpandMore from "@mui/icons-material/ExpandMore";
import StarBorder from "@mui/icons-material/StarBorder";
import PendingIcon from "@mui/icons-material/Pending";
import TaskAltIcon from "@mui/icons-material/TaskAlt";
import { MyContext, getBaseApiReact } from "../../App";
import { getBaseApi } from "../../background";
export const TaskManger = ({getUserInfo}) => {
const { txList, setTxList, memberGroups } = useContext(MyContext);
const [open, setOpen] = React.useState(true);
const handleClick = () => {
setOpen(!open);
};
const intervals = useRef({})
const getStatus = ({signature}, callback?: any) =>{
let stop = false
const getAnswer = async () => {
const getTx = async () => {
const url = `${getBaseApiReact()}/transactions/signature/${signature}`
const res = await fetch(url)
return await res.json()
}
if (!stop) {
stop = true
try {
const txTransaction = await getTx()
if (!txTransaction.error && txTransaction.signature) {
await new Promise((res)=> {
setTimeout(() => {
res(null)
}, 300000);
})
setTxList((prev)=> {
let previousData = [...prev];
const findTxWithSignature = previousData.findIndex((tx)=> tx.signature === signature)
if(findTxWithSignature !== -1){
previousData[findTxWithSignature].done = true;
return previousData
}
return previousData
})
if(callback){
callback(true)
}
clearInterval(intervals.current[signature])
}
} catch (error) { }
stop = false
}
}
intervals.current[signature] = setInterval(getAnswer, 120000)
}
useEffect(() => {
setTxList((prev) => {
let previousData = [...prev];
memberGroups.forEach((group) => {
const findGroup = txList.findIndex(
(tx) => tx?.type === "joined-group" && tx?.groupId === group.groupId
);
if (findGroup !== -1 && !previousData[findGroup]?.done ) {
// add notification
previousData[findGroup].done = true;
}
});
memberGroups.forEach((group) => {
const findGroup = txList.findIndex(
(tx) => tx?.type === "created-group" && tx?.groupName === group.groupName
);
if (findGroup !== -1 && !previousData[findGroup]?.done ) {
// add notification
previousData[findGroup].done = true;
}
});
prev.forEach((tx, index)=> {
if(tx?.type === "leave-group" && memberGroups.findIndex(
(group) => tx?.groupId === group.groupId
) === -1){
previousData[index].done = true;
}
})
prev.forEach((tx, index)=> {
if(tx?.type === "created-common-secret" && tx?.signature && !tx.done){
if(intervals.current[tx.signature]) return
getStatus({signature: tx.signature})
}
})
prev.forEach((tx, index)=> {
if(tx?.type === "joined-group-request" && tx?.signature && !tx.done){
if(intervals.current[tx.signature]) return
getStatus({signature: tx.signature})
}
})
prev.forEach((tx, index)=> {
if(tx?.type === "register-name" && tx?.signature && !tx.done){
if(intervals.current[tx.signature]) return
getStatus({signature: tx.signature}, getUserInfo)
}
})
return previousData;
});
}, [memberGroups, getUserInfo]);
if (txList?.length === 0 || txList.filter((item) => !item?.done).length === 0) return null;
return (
<List
sx={{ width: "100%", maxWidth: 360, bgcolor: "background.paper" }}
component="nav"
aria-labelledby="nested-list-subheader"
>
<ListItemButton onClick={handleClick}>
<ListItemIcon>
{txList.find((item) => !item.done) ? (
<PendingIcon sx={{
color: 'white'
}} />
) : (
<TaskAltIcon sx={{
color: 'white'
}} />
)}
</ListItemIcon>
<ListItemText primary="Ongoing Transactions" />
{open ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={open} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{txList.map((item) => {
return (
<ListItemButton key={item?.signature} sx={{ pl: 4 }}>
<ListItemText primary={item?.done ? item.labelDone : item.label} />
</ListItemButton>
);
})}
</List>
</Collapse>
</List>
);
};

1
src/constants/codes.ts Normal file
View File

@ -0,0 +1 @@
export const PUBLIC_NOTIFICATION_CODE_FIRST_SECRET_KEY = "4001"

0
src/constants/forum.ts Normal file
View File

View File

@ -25,6 +25,10 @@
padding: 0px; padding: 0px;
margin: 0px; margin: 0px;
box-sizing: border-box !important; box-sizing: border-box !important;
--color-instance : #1E1E20;
--color-instance-popover-bg: #222222;
--Mail-Background: rgba(49, 51, 56, 1);
--new-message-text: black;
} }
body { body {
@ -52,4 +56,28 @@ body {
.image-container:hover .base-image { .image-container:hover .base-image {
opacity: 0; opacity: 0;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-track:hover {
background-color: transparent;
}
::-webkit-scrollbar {
width: 16px;
height: 10px;
background-color: #232428;
}
::-webkit-scrollbar-thumb {
background-color: white;
border-radius: 8px;
background-clip: content-box;
border: 4px solid transparent;
}
.group-list::-webkit-scrollbar-thumb:hover {
background-color: whitesmoke;
} }

View File

@ -3,8 +3,53 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import './index.css' import './index.css'
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { CssBaseline } from '@mui/material';
const theme = createTheme({
palette: {
primary: {
main: '#232428', // Primary color (e.g., used for buttons, headers, etc.)
},
secondary: {
main: '#232428', // Secondary color
},
background: {
default: '#27282c', // Default background color
paper: '#1d1d1d', // Paper component background (for dropdowns, dialogs, etc.)
},
text: {
primary: '#ffffff', // White as the primary text color
secondary: '#b0b0b0', // Light gray for secondary text
disabled: '#808080', // Gray for disabled text
},
},
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif', // Font family
h1: {
color: '#ffffff', // White color for h1 elements
},
h2: {
color: '#ffffff', // White color for h2 elements
},
body1: {
color: '#ffffff', // Default body text color
},
body2: {
color: '#b0b0b0', // Lighter text for body2, often used for secondary text
},
},
});
export default theme;
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<App /> <App />
</ThemeProvider>
</React.StrictMode>, </React.StrictMode>,
) )

View File

@ -0,0 +1,266 @@
// @ts-nocheck
import Base58 from "../../deps/Base58"
import ed2curve from "../../deps/ed2curve"
import nacl from "../../deps/nacl-fast"
export function base64ToUint8Array(base64: string) {
const binaryString = atob(base64)
const len = binaryString.length
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes
}
export function uint8ArrayToBase64(uint8Array: any) {
const length = uint8Array.length
let binaryString = ''
const chunkSize = 1024 * 1024; // Process 1MB at a time
for (let i = 0; i < length; i += chunkSize) {
const chunkEnd = Math.min(i + chunkSize, length)
const chunk = uint8Array.subarray(i, chunkEnd)
binaryString += Array.from(chunk, byte => String.fromCharCode(byte)).join('')
}
return btoa(binaryString)
}
export function objectToBase64(obj: Object) {
// Step 1: Convert the object to a JSON string
const jsonString = JSON.stringify(obj)
// Step 2: Create a Blob from the JSON string
const blob = new Blob([jsonString], { type: 'application/json' })
// Step 3: Create a FileReader to read the Blob as a base64-encoded string
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
// Remove 'data:application/json;base64,' prefix
const base64 = reader.result.replace(
'data:application/json;base64,',
''
)
resolve(base64)
} else {
reject(new Error('Failed to read the Blob as a base64-encoded string'))
}
}
reader.onerror = () => {
reject(reader.error)
}
reader.readAsDataURL(blob)
})
}
// Function to create a symmetric key and nonce
export const createSymmetricKeyAndNonce = () => {
const messageKey = new Uint8Array(32); // 32 bytes for the symmetric key
crypto.getRandomValues(messageKey);
const nonce = new Uint8Array(24); // 24 bytes for the nonce
crypto.getRandomValues(nonce);
return { messageKey: uint8ArrayToBase64(messageKey), nonce: uint8ArrayToBase64(nonce) };
};
export const encryptDataGroup = ({ data64, publicKeys, privateKey, userPublicKey }: any) => {
let combinedPublicKeys = publicKeys
const decodedPrivateKey = Base58.decode(privateKey)
const publicKeysDuplicateFree = [...new Set(combinedPublicKeys)]
const Uint8ArrayData = base64ToUint8Array(data64)
if (!(Uint8ArrayData instanceof Uint8Array)) {
throw new Error("The Uint8ArrayData you've submitted is invalid")
}
try {
// Generate a random symmetric key for the message.
const messageKey = new Uint8Array(32)
crypto.getRandomValues(messageKey)
const nonce = new Uint8Array(24)
crypto.getRandomValues(nonce)
// Encrypt the data with the symmetric key.
const encryptedData = nacl.secretbox(Uint8ArrayData, nonce, messageKey)
// Generate a keyNonce outside of the loop.
const keyNonce = new Uint8Array(24)
crypto.getRandomValues(keyNonce)
// Encrypt the symmetric key for each recipient.
let encryptedKeys = []
publicKeysDuplicateFree.forEach((recipientPublicKey) => {
const publicKeyUnit8Array = Base58.decode(recipientPublicKey)
const convertedPrivateKey = ed2curve.convertSecretKey(decodedPrivateKey)
const convertedPublicKey = ed2curve.convertPublicKey(publicKeyUnit8Array)
const sharedSecret = new Uint8Array(32)
// the length of the sharedSecret will be 32 + 16
// When you're encrypting data using nacl.secretbox, it's adding an authentication tag to the result, which is 16 bytes long. This tag is used for verifying the integrity and authenticity of the data when it is decrypted
nacl.lowlevel.crypto_scalarmult(sharedSecret, convertedPrivateKey, convertedPublicKey)
// Encrypt the symmetric key with the shared secret.
const encryptedKey = nacl.secretbox(messageKey, keyNonce, sharedSecret)
encryptedKeys.push(encryptedKey)
})
const str = "qortalGroupEncryptedData"
const strEncoder = new TextEncoder()
const strUint8Array = strEncoder.encode(str)
// Convert sender's public key to Uint8Array and add to the message
const senderPublicKeyUint8Array = Base58.decode(userPublicKey)
// Combine all data into a single Uint8Array.
// Calculate size of combinedData
let combinedDataSize = strUint8Array.length + nonce.length + keyNonce.length + senderPublicKeyUint8Array.length + encryptedData.length + 4
let encryptedKeysSize = 0
encryptedKeys.forEach((key) => {
encryptedKeysSize += key.length
})
combinedDataSize += encryptedKeysSize
let combinedData = new Uint8Array(combinedDataSize)
combinedData.set(strUint8Array)
combinedData.set(nonce, strUint8Array.length)
combinedData.set(keyNonce, strUint8Array.length + nonce.length)
combinedData.set(senderPublicKeyUint8Array, strUint8Array.length + nonce.length + keyNonce.length)
combinedData.set(encryptedData, strUint8Array.length + nonce.length + keyNonce.length + senderPublicKeyUint8Array.length)
// Initialize offset for encryptedKeys
let encryptedKeysOffset = strUint8Array.length + nonce.length + keyNonce.length + senderPublicKeyUint8Array.length + encryptedData.length
encryptedKeys.forEach((key) => {
combinedData.set(key, encryptedKeysOffset)
encryptedKeysOffset += key.length
})
const countArray = new Uint8Array(new Uint32Array([publicKeysDuplicateFree.length]).buffer)
combinedData.set(countArray, combinedData.length - 4)
return uint8ArrayToBase64(combinedData)
} catch (error) {
throw new Error("Error in encrypting data")
}
}
export const encryptSingle = async ({ data64,secretKeyObject }: any) => {
const highestKey = Math.max(...Object.keys(secretKeyObject).filter(item=> !isNaN(+item)).map(Number));
const highestKeyObject = secretKeyObject[highestKey];
const Uint8ArrayData = base64ToUint8Array(data64)
const nonce = base64ToUint8Array(highestKeyObject.nonce)
const messageKey = base64ToUint8Array(highestKeyObject.messageKey)
if (!(Uint8ArrayData instanceof Uint8Array)) {
throw new Error("The Uint8ArrayData you've submitted is invalid")
}
try {
const encryptedData = nacl.secretbox(Uint8ArrayData, nonce, messageKey);
const encryptedDataBase64 = uint8ArrayToBase64(encryptedData)
const highestKeyStr = highestKey.toString().padStart(10, '0'); // Fixed length of 10 digits
const concatenatedData = highestKeyStr + encryptedDataBase64;
const finalEncryptedData = btoa(concatenatedData);
return finalEncryptedData;
} catch (error) {
throw new Error("Error in encrypting data")
}
}
export const decryptSingle = async ({ data64, secretKeyObject, skipDecodeBase64 }: any) => {
const decodedData = skipDecodeBase64 ? data64 : atob(data64);
// Extract the key (assuming it's 10 characters long)
const decodeForNumber = atob(decodedData)
const keyStr = decodeForNumber.slice(0, 10);
// Convert the key string back to a number
const highestKey = parseInt(keyStr, 10);
// Extract the remaining part as the Base64-encoded encrypted data
const encryptedDataBase64 = decodeForNumber.slice(10);
let _encryptedMessage = encryptedDataBase64
if(!secretKeyObject[highestKey]) throw new Error('Cannot find correct secretKey')
const nonce64 = secretKeyObject[highestKey].nonce
const messageKey64 = secretKeyObject[highestKey].messageKey
const Uint8ArrayData = base64ToUint8Array(_encryptedMessage)
const nonce = base64ToUint8Array(nonce64)
const messageKey = base64ToUint8Array(messageKey64)
if (!(Uint8ArrayData instanceof Uint8Array)) {
throw new Error("The Uint8ArrayData you've submitted is invalid")
}
// Decrypt the data using the nonce and messageKey
const decryptedData = nacl.secretbox.open(Uint8ArrayData, nonce, messageKey);
// Check if decryption was successful
if (!decryptedData) {
throw new Error("Decryption failed");
}
// Convert the decrypted Uint8Array back to a UTF-8 string
return uint8ArrayToBase64(decryptedData)
}
export function decryptGroupData(data64EncryptedData: string, privateKey: string) {
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, count}
}
}
}
throw new Error("Unable to decrypt data")
}

267
src/qdn/publish/pubish.ts Normal file
View File

@ -0,0 +1,267 @@
// @ts-nocheck
import { Buffer } from "buffer"
import Base58 from "../../deps/Base58"
import nacl from "../../deps/nacl-fast"
import utils from "../../utils/utils"
import { createEndpoint, getBaseApi } from "../../background";
export async function reusableGet(endpoint){
const validApi = await getBaseApi();
const response = await fetch(validApi + endpoint);
const data = await response.json();
return data
}
async function reusablePost(endpoint, _body){
// const validApi = await findUsableApi();
const url = await createEndpoint(endpoint)
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: _body
});
let data
try {
data = await response.clone().json()
} catch (e) {
data = await response.text()
}
return data
}
async function getKeyPair() {
const res = await chrome.storage.local.get(["keyPair"]);
if (res?.keyPair) {
return res.keyPair;
} else {
throw new Error("Wallet not authenticated");
}
}
export const publishData = async ({
registeredName,
file,
service,
identifier,
uploadType,
isBase64,
filename,
withFee,
title,
description,
category,
tag1,
tag2,
tag3,
tag4,
tag5,
feeAmount
}: any) => {
const validateName = async (receiverName: string) => {
return await reusableGet(`/names/${receiverName}`)
}
const convertBytesForSigning = async (transactionBytesBase58: string) => {
return await reusablePost('/transactions/convert', transactionBytesBase58)
}
const getArbitraryFee = async () => {
const timestamp = Date.now()
let fee = await reusableGet(`/transactions/unitfee?txType=ARBITRARY&timestamp=${timestamp}`)
return {
timestamp,
fee: Number(fee),
feeToShow: (Number(fee) / 1e8).toFixed(8)
}
}
const signArbitraryWithFee = (arbitraryBytesBase58, arbitraryBytesForSigningBase58, keyPair) => {
if (!arbitraryBytesBase58) {
throw new Error('ArbitraryBytesBase58 not defined')
}
if (!keyPair) {
throw new Error('keyPair not defined')
}
const arbitraryBytes = Base58.decode(arbitraryBytesBase58)
const _arbitraryBytesBuffer = Object.keys(arbitraryBytes).map(function (key) { return arbitraryBytes[key]; })
const arbitraryBytesBuffer = new Uint8Array(_arbitraryBytesBuffer)
const arbitraryBytesForSigning = Base58.decode(arbitraryBytesForSigningBase58)
const _arbitraryBytesForSigningBuffer = Object.keys(arbitraryBytesForSigning).map(function (key) { return arbitraryBytesForSigning[key]; })
const arbitraryBytesForSigningBuffer = new Uint8Array(_arbitraryBytesForSigningBuffer)
const signature = nacl.sign.detached(arbitraryBytesForSigningBuffer, keyPair.privateKey)
return utils.appendBuffer(arbitraryBytesBuffer, signature)
}
const processTransactionVersion2 = async (bytes) => {
return await reusablePost('/transactions/process?apiVersion=2', Base58.encode(bytes))
}
const signAndProcessWithFee = async (transactionBytesBase58: string) => {
let convertedBytesBase58 = await convertBytesForSigning(
transactionBytesBase58
)
if (convertedBytesBase58.error) {
throw new Error('Error when signing')
}
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 signedArbitraryBytes = signArbitraryWithFee(transactionBytesBase58, convertedBytesBase58, keyPair)
const response = await processTransactionVersion2(signedArbitraryBytes)
let myResponse = { error: '' }
if (response === false) {
throw new Error('Error when signing')
} else {
myResponse = response
}
return myResponse
}
const validate = async () => {
let validNameRes = await validateName(registeredName)
if (validNameRes.error) {
throw new Error('Name not found')
}
let fee = null
if (withFee && feeAmount) {
fee = feeAmount
} else if (withFee) {
const res = await getArbitraryFee()
if (res.fee) {
fee = res.fee
} else {
throw new Error('unable to get fee')
}
}
let transactionBytes = await uploadData(registeredName, file, fee)
if (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')
}
let signAndProcessRes
if (withFee) {
signAndProcessRes = await signAndProcessWithFee(transactionBytes)
}
if (signAndProcessRes?.error) {
throw new Error('Error when signing')
}
return signAndProcessRes
}
const uploadData = async (registeredName: string, file:any, fee: number) => {
if (identifier != null && identifier.trim().length > 0) {
let postBody = ''
let urlSuffix = ''
if (file != null) {
// If we're sending zipped data, make sure to use the /zip version of the POST /arbitrary/* API
if (uploadType === 'zip') {
urlSuffix = '/zip'
}
// If we're sending file data, use the /base64 version of the POST /arbitrary/* API
else if (uploadType === 'file') {
urlSuffix = '/base64'
}
// Base64 encode the file to work around compatibility issues between javascript and java byte arrays
if (isBase64) {
postBody = file
}
if (!isBase64) {
let fileBuffer = new Uint8Array(await file.arrayBuffer())
postBody = Buffer.from(fileBuffer).toString("base64")
}
}
let uploadDataUrl = `/arbitrary/${service}/${registeredName}${urlSuffix}`
if (identifier.trim().length > 0) {
uploadDataUrl = `/arbitrary/${service}/${registeredName}/${identifier}${urlSuffix}`
}
uploadDataUrl = uploadDataUrl + `?fee=${fee}`
if (filename != null && filename != 'undefined') {
uploadDataUrl = uploadDataUrl + '&filename=' + encodeURIComponent(filename)
}
if (title != null && title != 'undefined') {
uploadDataUrl = uploadDataUrl + '&title=' + encodeURIComponent(title)
}
if (description != null && description != 'undefined') {
uploadDataUrl = uploadDataUrl + '&description=' + encodeURIComponent(description)
}
if (category != null && category != 'undefined') {
uploadDataUrl = uploadDataUrl + '&category=' + encodeURIComponent(category)
}
if (tag1 != null && tag1 != 'undefined') {
uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag1)
}
if (tag2 != null && tag2 != 'undefined') {
uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag2)
}
if (tag3 != null && tag3 != 'undefined') {
uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag3)
}
if (tag4 != null && tag4 != 'undefined') {
uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag4)
}
if (tag5 != null && tag5 != 'undefined') {
uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag5)
}
return await reusablePost(uploadDataUrl, postBody)
}
}
try {
return await validate()
} catch (error: any) {
throw new Error(error?.message)
}
}

View File

@ -0,0 +1,37 @@
// @ts-nocheck
import { QORT_DECIMALS } from "../constants/constants"
import TransactionBase from "./TransactionBase"
export default class AddGroupAdminTransaction extends TransactionBase {
constructor() {
super()
this.type = 24
}
set rGroupId(rGroupId) {
this._rGroupId = rGroupId
this._rGroupIdBytes = this.constructor.utils.int32ToBytes(this._rGroupId)
}
set recipient(recipient) {
this._recipient = recipient instanceof Uint8Array ? recipient : this.constructor.Base58.decode(recipient)
this.theRecipient = recipient
}
set fee(fee) {
this._fee = fee * QORT_DECIMALS
this._feeBytes = this.constructor.utils.int64ToBytes(this._fee)
}
get params() {
const params = super.params
params.push(
this._rGroupIdBytes,
this._recipient,
this._feeBytes
)
return params
}
}

View File

@ -0,0 +1,37 @@
// @ts-nocheck
import { QORT_DECIMALS } from "../constants/constants"
import TransactionBase from "./TransactionBase"
export default class CancelGroupBanTransaction extends TransactionBase {
constructor() {
super()
this.type = 27
}
set rGroupId(rGroupId) {
this._rGroupId = rGroupId
this._rGroupIdBytes = this.constructor.utils.int32ToBytes(this._rGroupId)
}
set recipient(recipient) {
this._recipient = recipient instanceof Uint8Array ? recipient : this.constructor.Base58.decode(recipient)
this.theRecipient = recipient
}
set fee(fee) {
this._fee = fee * QORT_DECIMALS
this._feeBytes = this.constructor.utils.int64ToBytes(this._fee)
}
get params() {
const params = super.params
params.push(
this._rGroupIdBytes,
this._recipient,
this._feeBytes
)
return params
}
}

View File

@ -0,0 +1,36 @@
// @ts-nocheck
import { QORT_DECIMALS } from "../constants/constants"
import TransactionBase from "./TransactionBase"
export default class CancelGroupInviteTransaction extends TransactionBase {
constructor() {
super()
this.type = 30
}
set rGroupId(rGroupId) {
this._rGroupId = rGroupId
this._rGroupIdBytes = this.constructor.utils.int32ToBytes(this._rGroupId)
}
set recipient(recipient) {
this._recipient = recipient instanceof Uint8Array ? recipient : this.constructor.Base58.decode(recipient)
this.theRecipient = recipient
}
set fee(fee) {
this._fee = fee * QORT_DECIMALS
this._feeBytes = this.constructor.utils.int64ToBytes(this._fee)
}
get params() {
const params = super.params
params.push(
this._rGroupIdBytes,
this._recipient,
this._feeBytes
)
return params
}
}

View File

@ -19,6 +19,7 @@ export default class ChatBase {
} }
constructor() { constructor() {
this.fee = 0 this.fee = 0
this.groupID = 0 this.groupID = 0
this.tests = [ this.tests = [
@ -47,6 +48,7 @@ export default class ChatBase {
return true return true
}, },
() => { () => {
if (!(this._lastReference instanceof Uint8Array && this._lastReference.byteLength == 64)) { if (!(this._lastReference instanceof Uint8Array && this._lastReference.byteLength == 64)) {
return 'Invalid last reference: ' + this._lastReference return 'Invalid last reference: ' + this._lastReference
} }
@ -93,6 +95,7 @@ export default class ChatBase {
} }
set lastReference(lastReference) { set lastReference(lastReference) {
this._lastReference = lastReference instanceof Uint8Array ? lastReference : this.constructor.Base58.decode(lastReference) this._lastReference = lastReference instanceof Uint8Array ? lastReference : this.constructor.Base58.decode(lastReference)
} }

View File

@ -0,0 +1,64 @@
// @ts-nocheck
import { QORT_DECIMALS } from "../constants/constants"
import TransactionBase from "./TransactionBase"
export default class CreateGroupTransaction extends TransactionBase {
constructor() {
super()
this.type = 22
}
set fee(fee) {
this._fee = fee * QORT_DECIMALS
this._feeBytes = this.constructor.utils.int64ToBytes(this._fee)
}
set rGroupName(rGroupName) {
this._rGroupName = rGroupName
this._rGroupNameBytes = this.constructor.utils.stringtoUTF8Array(this._rGroupName)
this._rGroupNameLength = this.constructor.utils.int32ToBytes(this._rGroupNameBytes.length)
}
set rGroupDesc(rGroupDesc) {
this._rGroupDesc = rGroupDesc
this._rGroupDescBytes = this.constructor.utils.stringtoUTF8Array(this._rGroupDesc)
this._rGroupDescLength = this.constructor.utils.int32ToBytes(this._rGroupDescBytes.length)
}
set rGroupType(rGroupType) {
this._rGroupType = new Uint8Array(1)
this._rGroupType[0] = rGroupType
}
set rGroupApprovalThreshold(rGroupApprovalThreshold) {
this._rGroupApprovalThreshold = new Uint8Array(1)
this._rGroupApprovalThreshold[0] = rGroupApprovalThreshold
}
set rGroupMinimumBlockDelay(rGroupMinimumBlockDelay) {
this._rGroupMinimumBlockDelayBytes = this.constructor.utils.int32ToBytes(rGroupMinimumBlockDelay)
}
set rGroupMaximumBlockDelay(rGroupMaximumBlockDelay) {
this._rGroupMaximumBlockDelayBytes = this.constructor.utils.int32ToBytes(rGroupMaximumBlockDelay)
}
get params() {
const params = super.params
params.push(
this._rGroupNameLength,
this._rGroupNameBytes,
this._rGroupDescLength,
this._rGroupDescBytes,
this._rGroupType,
this._rGroupApprovalThreshold,
this._rGroupMinimumBlockDelayBytes,
this._rGroupMaximumBlockDelayBytes,
this._feeBytes
)
return params
}
}

View File

@ -0,0 +1,50 @@
// @ts-nocheck
import { QORT_DECIMALS } from "../constants/constants"
import TransactionBase from "./TransactionBase"
export default class GroupBanTransaction extends TransactionBase {
constructor() {
super()
this.type = 26
}
set rGroupId(rGroupId) {
this._rGroupId = rGroupId
this._rGroupIdBytes = this.constructor.utils.int32ToBytes(this._rGroupId)
}
set rBanReason(rBanReason) {
this._rBanReason = rBanReason
this._rBanReasonBytes = this.constructor.utils.stringtoUTF8Array(this._rBanReason)
this._rBanReasonLength = this.constructor.utils.int32ToBytes(this._rBanReasonBytes.length)
}
set rBanTime(rBanTime) {
this._rBanTime = rBanTime
this._rBanTimeBytes = this.constructor.utils.int32ToBytes(this._rBanTime)
}
set recipient(recipient) {
this._recipient = recipient instanceof Uint8Array ? recipient : this.constructor.Base58.decode(recipient)
this.theRecipient = recipient
}
set fee(fee) {
this._fee = fee * QORT_DECIMALS
this._feeBytes = this.constructor.utils.int64ToBytes(this._fee)
}
get params() {
const params = super.params
params.push(
this._rGroupIdBytes,
this._recipient,
this._rBanReasonLength,
this._rBanReasonBytes,
this._rBanTimeBytes,
this._feeBytes
)
return params
}
}

View File

@ -0,0 +1,72 @@
// @ts-nocheck
import ChatBase from './ChatBase'
import { CHAT_REFERENCE_FEATURE_TRIGGER_TIMESTAMP } from '../constants/constants'
export default class GroupChatTransaction extends ChatBase {
constructor() {
super();
this.type = 18
this.fee = 0
}
set proofOfWorkNonce(proofOfWorkNonce) {
this._proofOfWorkNonce = this.constructor.utils.int32ToBytes(proofOfWorkNonce)
}
set hasReceipient(hasReceipient) {
this._hasReceipient = new Uint8Array(1)
this._hasReceipient[0] = hasReceipient
}
set message(message) {
this.messageText = message
this._message = this.constructor.utils.stringtoUTF8Array(message)
this._messageLength = this.constructor.utils.int32ToBytes(this._message.length)
}
set hasChatReference(hasChatReference) {
this._hasChatReference = new Uint8Array(1)
this._hasChatReference[0] = hasChatReference
}
set chatReference(chatReference) {
this._chatReference = chatReference instanceof Uint8Array ? chatReference : this.constructor.Base58.decode(chatReference)
}
set isEncrypted(isEncrypted) {
this._isEncrypted = new Uint8Array(1)
this._isEncrypted[0] = isEncrypted
}
set isText(isText) {
this._isText = new Uint8Array(1)
this._isText[0] = isText
}
get params() {
const params = super.params
params.push(
this._proofOfWorkNonce,
this._hasReceipient,
this._messageLength,
this._message,
this._isEncrypted,
this._isText,
this._feeBytes
)
// After the feature trigger timestamp we need to include chat reference
if (new Date(this._timestamp).getTime() >= CHAT_REFERENCE_FEATURE_TRIGGER_TIMESTAMP) {
params.push(this._hasChatReference)
if (this._hasChatReference[0] == 1) {
params.push(this._chatReference)
}
}
return params
}
}

View File

@ -0,0 +1,42 @@
// @ts-nocheck
import { QORT_DECIMALS } from "../constants/constants"
import TransactionBase from "./TransactionBase"
export default class GroupInviteTransaction extends TransactionBase {
constructor() {
super()
this.type = 29
}
set rGroupId(rGroupId) {
this._rGroupId = rGroupId
this._rGroupIdBytes = this.constructor.utils.int32ToBytes(this._rGroupId)
}
set rInviteTime(rInviteTime) {
this._rInviteTime = rInviteTime
this._rInviteTimeBytes = this.constructor.utils.int32ToBytes(this._rInviteTime)
}
set recipient(recipient) {
this._recipient = recipient instanceof Uint8Array ? recipient : this.constructor.Base58.decode(recipient)
this.theRecipient = recipient
}
set fee(fee) {
this._fee = fee * QORT_DECIMALS
this._feeBytes = this.constructor.utils.int64ToBytes(this._fee)
}
get params() {
const params = super.params
params.push(
this._rGroupIdBytes,
this._recipient,
this._rInviteTimeBytes,
this._feeBytes
)
return params
}
}

View File

@ -0,0 +1,46 @@
// @ts-nocheck
import { QORT_DECIMALS } from "../constants/constants"
import TransactionBase from "./TransactionBase"
export default class GroupKickTransaction extends TransactionBase {
constructor() {
super()
this.type = 28
}
set rGroupId(rGroupId) {
this._rGroupId = rGroupId
this._rGroupIdBytes = this.constructor.utils.int32ToBytes(this._rGroupId)
}
set rBanReason(rBanReason) {
this._rBanReason = rBanReason
this._rBanReasonBytes = this.constructor.utils.stringtoUTF8Array(this._rBanReason)
this._rBanReasonLength = this.constructor.utils.int32ToBytes(this._rBanReasonBytes.length)
}
set recipient(recipient) {
this._recipient = recipient instanceof Uint8Array ? recipient : this.constructor.Base58.decode(recipient)
this.theRecipient = recipient
}
set fee(fee) {
this._fee = fee * QORT_DECIMALS
this._feeBytes = this.constructor.utils.int64ToBytes(this._fee)
}
get params() {
const params = super.params
params.push(
this._rGroupIdBytes,
this._recipient,
this._rBanReasonLength,
this._rBanReasonBytes,
this._feeBytes
)
return params
}
}

View File

@ -0,0 +1,38 @@
// @ts-nocheck
import { QORT_DECIMALS } from "../constants/constants"
import TransactionBase from "./TransactionBase"
export default class JoinGroupTransaction extends TransactionBase {
constructor() {
super()
this.type = 31
}
set fee(fee) {
this._fee = fee * QORT_DECIMALS
this._feeBytes = this.constructor.utils.int64ToBytes(this._fee)
}
set registrantAddress(registrantAddress) {
this._registrantAddress = registrantAddress instanceof Uint8Array ? registrantAddress : this.constructor.Base58.decode(registrantAddress)
}
set rGroupId(rGroupId) {
this._rGroupId = rGroupId
this._rGroupIdBytes = this.constructor.utils.int32ToBytes(this._rGroupId)
}
get params() {
const params = super.params
params.push(
this._rGroupIdBytes,
this._feeBytes
)
return params
}
}

View File

@ -0,0 +1,35 @@
// @ts-nocheck
import { QORT_DECIMALS } from "../constants/constants"
import TransactionBase from "./TransactionBase"
export default class LeaveGroupTransaction extends TransactionBase {
constructor() {
super()
this.type = 32
}
set fee(fee) {
this._fee = fee * QORT_DECIMALS
this._feeBytes = this.constructor.utils.int64ToBytes(this._fee)
}
set registrantAddress(registrantAddress) {
this._registrantAddress = registrantAddress instanceof Uint8Array ? registrantAddress : this.constructor.Base58.decode(registrantAddress)
}
set rGroupId(rGroupId) {
this._rGroupId = rGroupId
this._rGroupIdBytes = this.constructor.utils.int32ToBytes(this._rGroupId)
}
get params() {
const params = super.params
params.push(
this._rGroupIdBytes,
this._feeBytes
)
return params
}
}

View File

@ -0,0 +1,42 @@
// @ts-nocheck
import { QORT_DECIMALS } from "../constants/constants"
import TransactionBase from "./TransactionBase"
export default class RegisterNameTransaction extends TransactionBase {
constructor() {
super()
this.type = 3
}
set fee(fee) {
this._fee = fee * QORT_DECIMALS
this._feeBytes = this.constructor.utils.int64ToBytes(this._fee)
}
set name(name) {
this.nameText = name
this._nameBytes = this.constructor.utils.stringtoUTF8Array(name)
this._nameLength = this.constructor.utils.int32ToBytes(this._nameBytes.length)
}
set value(value) {
this.valueText = value.length === 0 ? "Registered Name on the Qortal Chain" : value
this._valueBytes = this.constructor.utils.stringtoUTF8Array(this.valueText)
this._valueLength = this.constructor.utils.int32ToBytes(this._valueBytes.length)
}
get params() {
const params = super.params
params.push(
this._nameLength,
this._nameBytes,
this._valueLength,
this._valueBytes,
this._feeBytes
)
return params
}
}

View File

@ -0,0 +1,38 @@
// @ts-nocheck
import { QORT_DECIMALS } from "../constants/constants"
import TransactionBase from "./TransactionBase"
export default class RemoveGroupAdminTransaction extends TransactionBase {
constructor() {
super()
this.type = 25
}
set rGroupId(rGroupId) {
this._rGroupId = rGroupId
this._rGroupIdBytes = this.constructor.utils.int32ToBytes(this._rGroupId)
}
set recipient(recipient) {
this._recipient = recipient instanceof Uint8Array ? recipient : this.constructor.Base58.decode(recipient)
this.theRecipient = recipient
}
set fee(fee) {
this._fee = fee * QORT_DECIMALS
this._feeBytes = this.constructor.utils.int64ToBytes(this._fee)
}
get params() {
const params = super.params
params.push(
this._rGroupIdBytes,
this._recipient,
this._feeBytes
)
return params
}
}

View File

@ -2,18 +2,45 @@
import PaymentTransaction from './PaymentTransaction.js' import PaymentTransaction from './PaymentTransaction.js'
import ChatTransaction from './ChatTransaction.js' import ChatTransaction from './ChatTransaction.js'
import GroupChatTransaction from './GroupChatTransaction.js'
import GroupInviteTransaction from './GroupInviteTransaction.js'
import CancelGroupInviteTransaction from './CancelGroupInviteTransaction.js'
import GroupKickTransaction from './GroupKickTransaction.js'
import GroupBanTransaction from './GroupBanTransaction.js'
import CancelGroupBanTransaction from './CancelGroupBanTransaction.js'
import CreateGroupTransaction from './CreateGroupTransaction.js'
import LeaveGroupTransaction from './LeaveGroupTransaction.js'
import JoinGroupTransaction from './JoinGroupTransaction.js'
import AddGroupAdminTransaction from './AddGroupAdminTransaction.js'
import RemoveGroupAdminTransaction from './RemoveGroupAdminTransaction.js'
import RegisterNameTransaction from './RegisterNameTransaction.js'
export const transactionTypes = { export const transactionTypes = {
3: RegisterNameTransaction,
2: PaymentTransaction, 2: PaymentTransaction,
18: ChatTransaction 18: ChatTransaction,
181: GroupChatTransaction,
22: CreateGroupTransaction,
24: AddGroupAdminTransaction,
25: RemoveGroupAdminTransaction,
26: GroupBanTransaction,
27: CancelGroupBanTransaction,
28: GroupKickTransaction,
29: GroupInviteTransaction,
30: CancelGroupInviteTransaction,
31: JoinGroupTransaction,
32: LeaveGroupTransaction
} }
export const createTransaction = (type, keyPair, params) => { export const createTransaction = (type, keyPair, params) => {
const tx = new transactionTypes[type]() const tx = new transactionTypes[type]()
tx.keyPair = keyPair tx.keyPair = keyPair
Object.keys(params).forEach(param => { Object.keys(params).forEach(param => {
tx[param] = params[param] tx[param] = params[param]
}) })

11
src/utils/Size/index.ts Normal file
View File

@ -0,0 +1,11 @@
export function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}

View File

@ -8,7 +8,15 @@ import {Sha256} from 'asmcrypto.js'
export const decryptChatMessage = (encryptedMessage, privateKey, recipientPublicKey, lastReference) => { export const decryptChatMessage = (encryptedMessage, privateKey, recipientPublicKey, lastReference) => {
const test = encryptedMessage const test = encryptedMessage
let _encryptedMessage = Base58.decode(encryptedMessage) let _encryptedMessage = atob(encryptedMessage)
const binaryLength = _encryptedMessage.length
const bytes = new Uint8Array(binaryLength)
for (let i = 0; i < binaryLength; i++) {
bytes[i] = _encryptedMessage.charCodeAt(i)
}
const _base58RecipientPublicKey = recipientPublicKey instanceof Uint8Array ? Base58.encode(recipientPublicKey) : recipientPublicKey const _base58RecipientPublicKey = recipientPublicKey instanceof Uint8Array ? Base58.encode(recipientPublicKey) : recipientPublicKey
const _recipientPublicKey = Base58.decode(_base58RecipientPublicKey) const _recipientPublicKey = Base58.decode(_base58RecipientPublicKey)
@ -20,10 +28,11 @@ export const decryptChatMessage = (encryptedMessage, privateKey, recipientPublic
nacl.lowlevel.crypto_scalarmult(sharedSecret, convertedPrivateKey, convertedPublicKey) nacl.lowlevel.crypto_scalarmult(sharedSecret, convertedPrivateKey, convertedPublicKey)
const _chatEncryptionSeed = new Sha256().process(sharedSecret).finish().result const _chatEncryptionSeed = new Sha256().process(sharedSecret).finish().result
const _decryptedMessage = nacl.secretbox.open(_encryptedMessage, _lastReference.slice(0, 24), _chatEncryptionSeed) const _decryptedMessage = nacl.secretbox.open(bytes, _lastReference.slice(0, 24), _chatEncryptionSeed)
let decryptedMessage = '' let decryptedMessage = ''
_decryptedMessage === false ? decryptedMessage : decryptedMessage = new TextDecoder('utf-8').decode(_decryptedMessage) _decryptedMessage === false ? decryptedMessage : decryptedMessage = new TextDecoder('utf-8').decode(_decryptedMessage)
return decryptedMessage return decryptedMessage
} }

11
src/utils/events.ts Normal file
View File

@ -0,0 +1,11 @@
export const executeEvent = (eventName: string, data: any)=> {
const event = new CustomEvent(eventName, {detail: data})
document.dispatchEvent(event)
}
export const subscribeToEvent = (eventName: string, listener: any)=> {
document.addEventListener(eventName, listener)
}
export const unsubscribeFromEvent = (eventName: string, listener: any)=> {
document.removeEventListener(eventName, listener)
}

View File

@ -92,7 +92,7 @@ export const createAccount = async()=> {
saveAs(blob, fileName); saveAs(blob, fileName);
} catch (error) { } catch (error) {
console.log({ error });
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
return; return;
} }

34
src/utils/helpers.ts Normal file
View File

@ -0,0 +1,34 @@
import moment from "moment";
export const delay = (time: number) => new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), time)
);
// const originalHtml = `<p>---------- Forwarded message ---------</p><p>From: Alex</p><p>Date: Mon, Jun 9 2014 9:32 PM</p><p>Subject: Batteries </p><p>To: Jessica</p><p><br></p><p><br></p>`;
// export function updateMessageDetails(newFrom: string, newDateMillis: number, newTo: string) {
// let htmlString = originalHtml
// // Use Moment.js to format the date from milliseconds
// const formattedDate = moment(newDateMillis).format('ddd, MMM D YYYY h:mm A');
// // Replace the From, Date, and To fields in the HTML string
// htmlString = htmlString.replace(/<p>From:.*?<\/p>/, `<p>From: ${newFrom}</p>`);
// htmlString = htmlString.replace(/<p>Date:.*?<\/p>/, `<p>Date: ${formattedDate}</p>`);
// htmlString = htmlString.replace(/<p>To:.*?<\/p>/, `<p>To: ${newTo}</p>`);
// return htmlString;
// }
const originalHtml = `<p>---------- Forwarded message ---------</p><p>From: Alex</p><p>Subject: Batteries </p><p>To: Jessica</p><p><br></p><p><br></p>`;
export function updateMessageDetails(newFrom: string, newSubject: string, newTo: string) {
let htmlString = originalHtml
htmlString = htmlString.replace(/<p>From:.*?<\/p>/, `<p>From: ${newFrom}</p>`);
htmlString = htmlString.replace(/<p>Subject:.*?<\/p>/, `<p>Subject: ${newSubject}</p>`);
htmlString = htmlString.replace(/<p>To:.*?<\/p>/, `<p>To: ${newTo}</p>`);
return htmlString;
}

View File

@ -0,0 +1,12 @@
export function convertQortalLinks(inputHtml: string) {
// Regular expression to match 'qortal://...' URLs.
// This will stop at the first whitespace, comma, or HTML tag
var regex = /(qortal:\/\/[^\s,<]+)/g;
// Replace matches in inputHtml with formatted anchor tag
var outputHtml = inputHtml.replace(regex, function (match) {
return `<a href="${match}" class="qortal-link">${match}</a>`;
});
return outputHtml;
}

56
src/utils/queue/queue.ts Normal file
View File

@ -0,0 +1,56 @@
export class RequestQueueWithPromise {
constructor(maxConcurrent = 5) {
this.queue = [];
this.maxConcurrent = maxConcurrent;
this.currentlyProcessing = 0;
this.isPaused = false; // Flag to track whether the queue is paused
}
// Add a request to the queue and return a promise
enqueue(request) {
return new Promise((resolve, reject) => {
// Push the request and its resolve and reject callbacks to the queue
this.queue.push({ request, resolve, reject });
this.process();
});
}
// Process requests in the queue
async process() {
// Process requests only if the queue is not paused
if (this.isPaused) return;
while (this.queue.length > 0 && this.currentlyProcessing < this.maxConcurrent) {
this.currentlyProcessing++;
const { request, resolve, reject } = this.queue.shift();
try {
const response = await request();
resolve(response);
} catch (error) {
reject(error);
} finally {
this.currentlyProcessing--;
await this.process();
}
}
}
// Pause the queue processing
pause() {
this.isPaused = true;
}
// Resume the queue processing
resume() {
this.isPaused = false;
this.process(); // Continue processing when resumed
}
// Clear pending requests in the queue
clear() {
this.queue.length = 0;
}
}

38
src/utils/time.ts Normal file
View File

@ -0,0 +1,38 @@
import moment from "moment"
export function formatTimestamp(timestamp: number): string {
const now = moment()
const timestampMoment = moment(timestamp)
const elapsedTime = now.diff(timestampMoment, 'minutes')
if (elapsedTime < 1) {
return 'Just now'
} else if (elapsedTime < 60) {
return `${elapsedTime}m ago`
} else if (elapsedTime < 1440) {
return `${Math.floor(elapsedTime / 60)}h ago`
} else {
return timestampMoment.format('MMM D')
}
}
export function formatTimestampForum(timestamp: number): string {
const now = moment();
const timestampMoment = moment(timestamp);
const elapsedTime = now.diff(timestampMoment, 'minutes');
if (elapsedTime < 1) {
return `Just now - ${timestampMoment.format('h:mm A')}`;
} else if (elapsedTime < 60) {
return `${elapsedTime}m ago - ${timestampMoment.format('h:mm A')}`;
} else if (elapsedTime < 1440) {
return `${Math.floor(elapsedTime / 60)}h ago - ${timestampMoment.format('h:mm A')}`;
} else {
return timestampMoment.format('MMM D, YYYY - h:mm A');
}
}
export const formatDate = (unixTimestamp: number): string => {
const date = moment(unixTimestamp, 'x').fromNow()
return date
}