version 2 - beta
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Qortal Extension</title>
|
||||
</head>
|
||||
|
3026
package-lock.json
generated
36
package.json
@ -5,43 +5,73 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chatscope/chat-ui-kit-react": "^2.0.3",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@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/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",
|
||||
"asmcrypto.js": "2.3.2",
|
||||
"bcryptjs": "2.4.3",
|
||||
"buffer": "6.0.3",
|
||||
"compressorjs": "^1.2.1",
|
||||
"dompurify": "^3.1.6",
|
||||
"file-saver": "^2.0.5",
|
||||
"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-copy-to-clipboard": "^5.1.0",
|
||||
"react-countdown-circle-timer": "^3.2.1",
|
||||
"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": {
|
||||
"@testing-library/dom": "^10.3.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-copy-to-clipboard": "^5.0.7",
|
||||
"@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/parser": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"rename-cli": "^6.2.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vitest": "^1.6.0"
|
||||
|
@ -1,3 +1,4 @@
|
||||
|
||||
async function connection(hostname) {
|
||||
const isConnected = await chrome.storage.local.get([hostname]);
|
||||
let connected = false
|
||||
@ -48,6 +49,7 @@ document.addEventListener('qortalExtensionRequests', async (event) => {
|
||||
}
|
||||
});
|
||||
} else if (type === 'REQUEST_CONNECTION') {
|
||||
console.log('REQUEST_CONNECTION')
|
||||
const hostname = window.location.hostname
|
||||
chrome.runtime.sendMessage({ action: "connection", payload: {
|
||||
hostname
|
||||
@ -137,6 +139,7 @@ document.addEventListener('qortalExtensionRequests', async (event) => {
|
||||
chrome.runtime.sendMessage({ action: "ltcBalance", payload: {
|
||||
hostname
|
||||
}, timeout }, (response) => {
|
||||
|
||||
if (response.error) {
|
||||
document.dispatchEvent(new CustomEvent('qortalExtensionResponses', {
|
||||
detail: { type: "LTC_BALANCE", data: {
|
||||
|
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
@ -14,7 +14,7 @@
|
||||
},
|
||||
"action": {
|
||||
},
|
||||
"permissions": [ "storage", "system.display", "activeTab", "tabs"
|
||||
"permissions": [ "storage", "system.display", "activeTab", "tabs", "notifications", "alarms"
|
||||
],
|
||||
|
||||
"content_scripts": [
|
||||
@ -24,6 +24,6 @@
|
||||
}
|
||||
],
|
||||
"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
@ -40,6 +40,15 @@ export const AuthenticatedContainerInnerRight = styled(Box)(({ theme }) => ({
|
||||
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 }) => ({
|
||||
fontSize: "13px",
|
||||
|
1066
src/App.tsx
3
src/assets/svgs/ArrowDown.svg
Normal 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 |
3
src/assets/svgs/Attachment.svg
Normal 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 |
3
src/assets/svgs/Check.svg
Normal 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 |
9
src/assets/svgs/ComposeIcon copy.svg
Normal 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 |
9
src/assets/svgs/ComposeIcon.svg
Normal 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 |
20
src/assets/svgs/CreateThreadIcon.tsx
Normal 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>
|
||||
|
||||
|
||||
);
|
||||
};
|
3
src/assets/svgs/ModalClose.svg
Normal 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
@ -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 |
19
src/assets/svgs/SendNewMessage.tsx
Normal 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
@ -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 |
6
src/assets/svgs/interfaces.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface SVGProps {
|
||||
color: string
|
||||
height: string
|
||||
width: string
|
||||
opacity?: number
|
||||
}
|
2960
src/background.ts
208
src/backgroundFunctions/encryption.ts
Normal 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)
|
||||
}
|
7
src/common/CustomLoader.tsx
Normal 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
@ -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
|
64
src/common/customloader.css
Normal 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
@ -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
|
||||
};
|
||||
};
|
344
src/components/Chat/AnnouncementDiscussion.tsx
Normal 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>
|
||||
);
|
||||
};
|
167
src/components/Chat/AnnouncementItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
96
src/components/Chat/AnnouncementList.tsx
Normal 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>
|
||||
);
|
||||
};
|
56
src/components/Chat/ChatContainer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
305
src/components/Chat/ChatDirect.tsx
Normal 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>
|
||||
)
|
||||
}
|
377
src/components/Chat/ChatGroup.tsx
Normal 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>
|
||||
)
|
||||
}
|
144
src/components/Chat/ChatList.tsx
Normal 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>
|
||||
);
|
||||
};
|
79
src/components/Chat/CreateCommonSecret.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
59
src/components/Chat/CustomImage.ts
Normal 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;
|
607
src/components/Chat/GroupAnnouncements.tsx
Normal 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>
|
||||
);
|
||||
};
|
52
src/components/Chat/GroupForum.tsx
Normal 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>
|
||||
);
|
||||
};
|
66
src/components/Chat/MessageDisplay.tsx
Normal 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
93
src/components/Chat/MessageItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
63
src/components/Chat/ResizableImage.tsx
Normal 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;
|
292
src/components/Chat/TipTap.tsx
Normal 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
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
121
src/components/Chat/styles.css
Normal 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%;
|
||||
}
|
474
src/components/Group/AddGroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
272
src/components/Group/AddGroupList.tsx
Normal 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>
|
||||
);
|
||||
};
|
45
src/components/Group/Forum/DisplayHtml.tsx
Normal 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>
|
||||
);
|
||||
};
|
777
src/components/Group/Forum/GroupMail.tsx
Normal 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>
|
||||
);
|
||||
};
|
799
src/components/Group/Forum/Mail-styles.ts
Normal 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)",
|
||||
},
|
||||
}));
|
554
src/components/Group/Forum/NewThread.tsx
Normal 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>
|
||||
);
|
||||
};
|
129
src/components/Group/Forum/ReadOnlySlate.tsx
Normal 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;
|
57
src/components/Group/Forum/ReusableModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
224
src/components/Group/Forum/ShowMessageWithoutModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
39
src/components/Group/Forum/TextEditor.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
329
src/components/Group/Forum/Thread copy.tsx
Normal 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>
|
||||
)
|
||||
}
|
663
src/components/Group/Forum/Thread.tsx
Normal 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>
|
||||
);
|
||||
};
|
71
src/components/Group/Forum/texteditor.css
Normal 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;
|
||||
}
|
1943
src/components/Group/Group.tsx
Normal file
114
src/components/Group/GroupInvites.tsx
Normal 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>
|
||||
);
|
||||
};
|
170
src/components/Group/GroupJoinRequests.tsx
Normal 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>
|
||||
);
|
||||
};
|
108
src/components/Group/InviteMember.tsx
Normal 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>
|
||||
);
|
||||
};
|
188
src/components/Group/ListOfBans.tsx
Normal 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>
|
||||
);
|
||||
}
|
189
src/components/Group/ListOfInvites.tsx
Normal 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>
|
||||
);
|
||||
}
|
189
src/components/Group/ListOfJoinRequests.tsx
Normal 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>
|
||||
);
|
||||
}
|
385
src/components/Group/ListOfMembers.tsx
Normal 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;
|
138
src/components/Group/ListOfThreadPostsWatched.tsx
Normal 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>
|
||||
);
|
||||
};
|
316
src/components/Group/ManageMembers.tsx
Normal 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>
|
||||
);
|
||||
};
|
166
src/components/Group/ThingsToDoInitial.tsx
Normal 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>
|
||||
);
|
||||
};
|
206
src/components/Group/UserListOfInvites.tsx
Normal 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>
|
||||
);
|
||||
}
|
109
src/components/Group/WebsocketActive.tsx
Normal 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;
|
||||
};
|
21
src/components/Snackbar/LoadingSnackbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
38
src/components/Snackbar/Snackbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
175
src/components/TaskManager/TaskManger.tsx
Normal 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
@ -0,0 +1 @@
|
||||
export const PUBLIC_NOTIFICATION_CODE_FIRST_SECRET_KEY = "4001"
|
0
src/constants/forum.ts
Normal file
@ -25,6 +25,10 @@
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
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 {
|
||||
@ -53,3 +57,27 @@ body {
|
||||
.image-container:hover .base-image {
|
||||
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;
|
||||
}
|
45
src/main.tsx
@ -3,8 +3,53 @@ import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
266
src/qdn/encryption/group-encryption.ts
Normal 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
@ -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×tamp=${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)
|
||||
}
|
||||
}
|
37
src/transactions/AddGroupAdminTransaction.ts
Normal 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
|
||||
}
|
||||
}
|
37
src/transactions/CancelGroupBanTransaction.ts
Normal 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
|
||||
}
|
||||
}
|
36
src/transactions/CancelGroupInviteTransaction.ts
Normal 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
|
||||
}
|
||||
}
|
@ -19,6 +19,7 @@ export default class ChatBase {
|
||||
}
|
||||
|
||||
constructor() {
|
||||
|
||||
this.fee = 0
|
||||
this.groupID = 0
|
||||
this.tests = [
|
||||
@ -47,6 +48,7 @@ export default class ChatBase {
|
||||
return true
|
||||
},
|
||||
() => {
|
||||
|
||||
if (!(this._lastReference instanceof Uint8Array && this._lastReference.byteLength == 64)) {
|
||||
return 'Invalid last reference: ' + this._lastReference
|
||||
}
|
||||
@ -93,6 +95,7 @@ export default class ChatBase {
|
||||
}
|
||||
|
||||
set lastReference(lastReference) {
|
||||
|
||||
this._lastReference = lastReference instanceof Uint8Array ? lastReference : this.constructor.Base58.decode(lastReference)
|
||||
}
|
||||
|
||||
|
64
src/transactions/CreateGroupTransaction.ts
Normal 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
|
||||
}
|
||||
}
|
50
src/transactions/GroupBanTransaction.ts
Normal 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
|
||||
}
|
||||
}
|
72
src/transactions/GroupChatTransaction.ts
Normal 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
|
||||
}
|
||||
}
|
42
src/transactions/GroupInviteTransaction.ts
Normal 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
|
||||
}
|
||||
}
|
46
src/transactions/GroupKickTransaction.ts
Normal 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
|
||||
}
|
||||
}
|
38
src/transactions/JoinGroupTransaction.ts
Normal 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
|
||||
}
|
||||
}
|
35
src/transactions/LeaveGroupTransaction.ts
Normal 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
|
||||
}
|
||||
}
|
42
src/transactions/RegisterNameTransaction.ts
Normal 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
|
||||
}
|
||||
}
|
38
src/transactions/RemoveGroupAdminTransaction.ts
Normal 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
|
||||
}
|
||||
}
|
@ -2,18 +2,45 @@
|
||||
|
||||
import PaymentTransaction from './PaymentTransaction.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 = {
|
||||
3: RegisterNameTransaction,
|
||||
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) => {
|
||||
|
||||
const tx = new transactionTypes[type]()
|
||||
|
||||
tx.keyPair = keyPair
|
||||
Object.keys(params).forEach(param => {
|
||||
|
||||
tx[param] = params[param]
|
||||
})
|
||||
|
||||
|
11
src/utils/Size/index.ts
Normal 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];
|
||||
}
|
@ -8,7 +8,15 @@ import {Sha256} from 'asmcrypto.js'
|
||||
|
||||
export const decryptChatMessage = (encryptedMessage, privateKey, recipientPublicKey, lastReference) => {
|
||||
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 _recipientPublicKey = Base58.decode(_base58RecipientPublicKey)
|
||||
|
||||
@ -20,10 +28,11 @@ export const decryptChatMessage = (encryptedMessage, privateKey, recipientPublic
|
||||
nacl.lowlevel.crypto_scalarmult(sharedSecret, convertedPrivateKey, convertedPublicKey)
|
||||
|
||||
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 = ''
|
||||
|
||||
_decryptedMessage === false ? decryptedMessage : decryptedMessage = new TextDecoder('utf-8').decode(_decryptedMessage)
|
||||
|
||||
return decryptedMessage
|
||||
}
|
11
src/utils/events.ts
Normal 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)
|
||||
}
|
@ -92,7 +92,7 @@ export const createAccount = async()=> {
|
||||
|
||||
saveAs(blob, fileName);
|
||||
} catch (error) {
|
||||
console.log({ error });
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
34
src/utils/helpers.ts
Normal 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;
|
||||
}
|
12
src/utils/qortalLink/index.ts
Normal 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
@ -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
@ -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
|
||||
}
|