From a85e451d2f7ef78a6e3d14fa6dbfa9b87ea83446 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sun, 1 Dec 2024 09:34:53 +0200 Subject: [PATCH] added admin space and qortalrequests --- src/assets/Icons/AdminsIcon.tsx | 15 +++ src/background-cases.ts | 37 +++++- src/background.ts | 4 + src/backgroundFunctions/encryption.ts | 71 ++++++++++ src/components/Chat/AdminSpace.tsx | 66 ++++++++++ src/components/Chat/AdminSpaceInner.tsx | 150 ++++++++++++++++++++++ src/components/Chat/ChatGroup.tsx | 24 ++-- src/components/Desktop/DesktopHeader.tsx | 25 ++++ src/components/Group/Forum/Mail-styles.ts | 2 +- src/components/Group/Group.tsx | 5 +- src/qortalRequests/get.ts | 83 +++++++++++- 11 files changed, 464 insertions(+), 18 deletions(-) create mode 100644 src/assets/Icons/AdminsIcon.tsx create mode 100644 src/components/Chat/AdminSpace.tsx create mode 100644 src/components/Chat/AdminSpaceInner.tsx diff --git a/src/assets/Icons/AdminsIcon.tsx b/src/assets/Icons/AdminsIcon.tsx new file mode 100644 index 0000000..d2c89c6 --- /dev/null +++ b/src/assets/Icons/AdminsIcon.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +export const AdminsIcon= ({ color = 'white', height = 48, width = 50 }) => { + return ( + + + + + + + + + ); + }; + \ No newline at end of file diff --git a/src/background-cases.ts b/src/background-cases.ts index 64ca4d0..0eaed21 100644 --- a/src/background-cases.ts +++ b/src/background-cases.ts @@ -54,7 +54,7 @@ import { updateThreadActivity, walletVersion, } from "./background"; -import { decryptGroupEncryption, encryptAndPublishSymmetricKeyGroupChat, publishGroupEncryptedResource, publishOnQDN } from "./backgroundFunctions/encryption"; +import { decryptGroupEncryption, encryptAndPublishSymmetricKeyGroupChat, encryptAndPublishSymmetricKeyGroupChatForAdmins, publishGroupEncryptedResource, publishOnQDN } from "./backgroundFunctions/encryption"; import { PUBLIC_NOTIFICATION_CODE_FIRST_SECRET_KEY } from "./constants/codes"; import { encryptSingle } from "./qdn/encryption/group-encryption"; import { _createPoll, _voteOnPoll } from "./qortalRequests/get"; @@ -1411,6 +1411,41 @@ export async function encryptAndPublishSymmetricKeyGroupChatCase( } } +export async function encryptAndPublishSymmetricKeyGroupChatForAdminsCase( + request, + event +) { + try { + const { groupId, previousData, admins } = request.payload; + const { data, numberOfMembers } = + await encryptAndPublishSymmetricKeyGroupChatForAdmins({ + groupId, + previousData, + admins + }); + + event.source.postMessage( + { + requestId: request.requestId, + action: "encryptAndPublishSymmetricKeyGroupChatForAdmins", + payload: data, + type: "backgroundMessageResponse", + }, + event.origin + ); + } catch (error) { + event.source.postMessage( + { + requestId: request.requestId, + action: "encryptAndPublishSymmetricKeyGroupChat", + error: error?.message, + type: "backgroundMessageResponse", + }, + event.origin + ); + } +} + export async function publishGroupEncryptedResourceCase(request, event) { try { const {encryptedData, identifier} = request.payload; diff --git a/src/background.ts b/src/background.ts index 7532956..da726c3 100644 --- a/src/background.ts +++ b/src/background.ts @@ -52,6 +52,7 @@ import { decryptSingleForPublishesCase, decryptWalletCase, encryptAndPublishSymmetricKeyGroupChatCase, + encryptAndPublishSymmetricKeyGroupChatForAdminsCase, encryptSingleCase, getApiKeyCase, getCustomNodesFromStorageCase, @@ -2956,6 +2957,9 @@ function setupMessageListener() { case "encryptAndPublishSymmetricKeyGroupChat": encryptAndPublishSymmetricKeyGroupChatCase(request, event); break; + case "encryptAndPublishSymmetricKeyGroupChatForAdmins": + encryptAndPublishSymmetricKeyGroupChatForAdminsCase(request, event); + break; case "publishGroupEncryptedResource": publishGroupEncryptedResourceCase(request, event); break; diff --git a/src/backgroundFunctions/encryption.ts b/src/backgroundFunctions/encryption.ts index 3511597..badd0aa 100644 --- a/src/backgroundFunctions/encryption.ts +++ b/src/backgroundFunctions/encryption.ts @@ -85,6 +85,26 @@ const getPublicKeys = async (groupNumber: number) => { return members } + export const getPublicKeysByAddress = async (admins) => { + const validApi = await getBaseApi() + + + let members: any = []; + if (Array.isArray(admins)) { + for (const address of admins) { + if (address) { + const resAddress = await fetch(`${validApi}/addresses/${address}`); + const resData = await resAddress.json(); + const publicKey = resData.publicKey; + members.push(publicKey) + } + } + } + + return members + } + + export const encryptAndPublishSymmetricKeyGroupChat = async ({groupId, previousData}: { @@ -136,6 +156,57 @@ export const encryptAndPublishSymmetricKeyGroupChat = async ({groupId, previousD throw new Error(error.message); } } +export const encryptAndPublishSymmetricKeyGroupChatForAdmins = async ({groupId, previousData, admins}: { + 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 = resKeyPair + const privateKey = parsedData.privateKey + const userPublicKey = parsedData.publicKey + const groupmemberPublicKeys = await getPublicKeysByAddress(admins.map((admin)=> admin.address)) + + + 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: `admins-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 { diff --git a/src/components/Chat/AdminSpace.tsx b/src/components/Chat/AdminSpace.tsx new file mode 100644 index 0000000..f340e45 --- /dev/null +++ b/src/components/Chat/AdminSpace.tsx @@ -0,0 +1,66 @@ +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { GroupMail } from "../Group/Forum/GroupMail"; +import { MyContext, isMobile } from "../../App"; +import { getRootHeight } from "../../utils/mobile/mobileUtils"; +import { Box, Typography } from "@mui/material"; +import { AdminSpaceInner } from "./AdminSpaceInner"; + + + + + + +export const AdminSpace = ({ + selectedGroup, + adminsWithNames, + userInfo, + secretKey, + getSecretKey, + isAdmin, + myAddress, + hide, + defaultThread, + setDefaultThread +}) => { + const { rootHeight } = useContext(MyContext); + 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 ( +
+ {!isAdmin && Sorry, this space is only for Admins.} + {isAdmin && } + +
+ ); +}; diff --git a/src/components/Chat/AdminSpaceInner.tsx b/src/components/Chat/AdminSpaceInner.tsx new file mode 100644 index 0000000..1307ad1 --- /dev/null +++ b/src/components/Chat/AdminSpaceInner.tsx @@ -0,0 +1,150 @@ +import React, { useCallback, useContext, useEffect, useState } from 'react' +import { MyContext, getArbitraryEndpointReact, getBaseApiReact } from '../../App'; +import { Box, Button, Typography } from '@mui/material'; +import { decryptResource, validateSecretKey } from '../Group/Group'; +import { getFee } from '../../background'; +import { base64ToUint8Array } from '../../qdn/encryption/group-encryption'; +import { uint8ArrayToObject } from '../../backgroundFunctions/encryption'; +import { formatTimestampForum } from '../../utils/time'; +import { Spacer } from '../../common/Spacer'; + + +export const getPublishesFromAdminsAdminSpace = async (admins: string[], groupId) => { + const queryString = admins.map((name) => `name=${name}`).join("&"); + const url = `${getBaseApiReact()}${getArbitraryEndpointReact()}?mode=ALL&service=DOCUMENT_PRIVATE&identifier=admins-symmetric-qchat-group-${ + groupId + }&exactmatchnames=true&limit=0&reverse=true&${queryString}&prefix=true`; + const response = await fetch(url); + if (!response.ok) { + throw new Error("network error"); + } + const adminData = await response.json(); + + const filterId = adminData.filter( + (data: any) => + data.identifier === `admins-symmetric-qchat-group-${groupId}` + ); + if (filterId?.length === 0) { + return false; + } + const sortedData = filterId.sort((a: any, b: any) => { + // Get the most recent date for both a and b + const dateA = a.updated ? new Date(a.updated) : new Date(a.created); + const dateB = b.updated ? new Date(b.updated) : new Date(b.created); + + // Sort by most recent + return dateB.getTime() - dateA.getTime(); + }); + + return sortedData[0]; + }; + +export const AdminSpaceInner = ({selectedGroup, adminsWithNames}) => { + const [adminGroupSecretKey, setAdminGroupSecretKey] = useState(null) + const [isFetchingAdminGroupSecretKey, setIsFetchingAdminGroupSecretKey] = useState(true) + const [adminGroupSecretKeyPublishDetails, setAdminGroupSecretKeyPublishDetails] = useState(null) + + const [isLoadingPublishKey, setIsLoadingPublishKey] = useState(false) + const { show, setTxList, setInfoSnackCustom, + setOpenSnackGlobal } = useContext(MyContext); + + + const getAdminGroupSecretKey = useCallback(async ()=> { + try { + if(!selectedGroup) return + const getLatestPublish = await getPublishesFromAdminsAdminSpace(adminsWithNames.map((admin)=> admin?.name), selectedGroup) + if(getLatestPublish === false) return + let data; + + const res = await fetch( + `${getBaseApiReact()}/arbitrary/DOCUMENT_PRIVATE/${getLatestPublish.name}/${ + getLatestPublish.identifier + }?encoding=base64` + ); + data = await res.text(); + + const decryptedKey: any = await decryptResource(data); + const dataint8Array = base64ToUint8Array(decryptedKey.data); + const decryptedKeyToObject = uint8ArrayToObject(dataint8Array); + if (!validateSecretKey(decryptedKeyToObject)) + throw new Error("SecretKey is not valid"); + setAdminGroupSecretKey(decryptedKeyToObject) + setAdminGroupSecretKeyPublishDetails(getLatestPublish) + } catch (error) { + + } finally { + setIsFetchingAdminGroupSecretKey(false) + } + }, [adminsWithNames, selectedGroup]) + + const createCommonSecretForAdmins = async ()=> { + try { + const fee = await getFee('ARBITRARY') + await show({ + message: "Would you like to perform an ARBITRARY transaction?" , + publishFee: fee.fee + ' QORT' + }) + setIsLoadingPublishKey(true) + + + window.sendMessage("encryptAndPublishSymmetricKeyGroupChatForAdmins", { + groupId: selectedGroup, + previousData: null, + admins: adminsWithNames + }) + .then((response) => { + + if (!response?.error) { + setInfoSnackCustom({ + type: "success", + message: "Successfully re-encrypted secret key. It may take a couple of minutes for the changes to propagate. Refresh the group in 5 mins.", + }); + setOpenSnackGlobal(true); + return + } + setInfoSnackCustom({ + type: "error", + message: response?.error || "unable to re-encrypt secret key", + }); + setOpenSnackGlobal(true); + }) + .catch((error) => { + setInfoSnackCustom({ + type: "error", + message: error?.message || "unable to re-encrypt secret key", + }); + setOpenSnackGlobal(true); + }); + + } catch (error) { + + } + } + useEffect(() => { + getAdminGroupSecretKey() + }, [getAdminGroupSecretKey]); + return ( + + + + {isFetchingAdminGroupSecretKey && Fetching Admins secret keys} + {!isFetchingAdminGroupSecretKey && !adminGroupSecretKey && No secret key published yet} + {adminGroupSecretKeyPublishDetails && ( + Last encryption date: {formatTimestampForum(adminGroupSecretKeyPublishDetails?.updated || adminGroupSecretKeyPublishDetails?.created)} + )} + + + + ) +} diff --git a/src/components/Chat/ChatGroup.tsx b/src/components/Chat/ChatGroup.tsx index 093d535..9d38ce8 100644 --- a/src/components/Chat/ChatGroup.tsx +++ b/src/components/Chat/ChatGroup.tsx @@ -23,6 +23,7 @@ import { RESOURCE_TYPE_NUMBER_GROUP_CHAT_REACTIONS } from '../../constants/resou import { isExtMsg } from '../../background' import AppViewerContainer from '../Apps/AppViewerContainer' import CloseIcon from "@mui/icons-material/Close"; +import { throttle } from 'lodash' const uid = new ShortUniqueId({ length: 5 }); @@ -50,7 +51,8 @@ const [messageSize, setMessageSize] = useState(0) const [, forceUpdate] = useReducer((x) => x + 1, 0); const lastReadTimestamp = useRef(null) - + const handleUpdateRef = useRef(null); + const getTimestampEnterChat = async () => { try { @@ -624,21 +626,21 @@ const clearEditorContent = () => { useEffect(() => { if (!editorRef?.current) return; - const handleUpdate = () => { - const htmlContent = editorRef?.current.getHTML(); - const stringified = JSON.stringify(htmlContent); - const size = new Blob([stringified]).size; + + handleUpdateRef.current = throttle(() => { + const htmlContent = editorRef.current.getHTML(); + const size = new TextEncoder().encode(htmlContent).length; setMessageSize(size + 100); - }; + }, 1200); - // Add a listener for the editorRef?.current's content updates - editorRef?.current.on('update', handleUpdate); + const currentEditor = editorRef.current; + + currentEditor.on("update", handleUpdateRef.current); - // Cleanup the listener on unmount return () => { - editorRef?.current.off('update', handleUpdate); + currentEditor.off("update", handleUpdateRef.current); }; - }, [editorRef?.current]); + }, [editorRef, setMessageSize]); useEffect(() => { if (hide) { diff --git a/src/components/Desktop/DesktopHeader.tsx b/src/components/Desktop/DesktopHeader.tsx index 6bb30a6..7067153 100644 --- a/src/components/Desktop/DesktopHeader.tsx +++ b/src/components/Desktop/DesktopHeader.tsx @@ -18,6 +18,7 @@ import { NotificationIcon2 } from "../../assets/Icons/NotificationIcon2"; import { ChatIcon } from "../../assets/Icons/ChatIcon"; import { ThreadsIcon } from "../../assets/Icons/ThreadsIcon"; import { MembersIcon } from "../../assets/Icons/MembersIcon"; +import { AdminsIcon } from "../../assets/Icons/AdminsIcon"; const IconWrapper = ({ children, label, color, selected, selectColor, customHeight }) => { return ( @@ -278,6 +279,30 @@ export const DesktopHeader = ({ /> + { + setGroupSection("adminSpace"); + + }} + > + + + + ); diff --git a/src/components/Group/Forum/Mail-styles.ts b/src/components/Group/Forum/Mail-styles.ts index 5308bf0..534304d 100644 --- a/src/components/Group/Forum/Mail-styles.ts +++ b/src/components/Group/Forum/Mail-styles.ts @@ -729,7 +729,7 @@ font-size: 23px; font-style: normal; font-weight: 700; line-height: normal; -white-space: nowrap; +white-space: wrap; text-overflow: ellipsis; overflow: hidden; ` diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 1b89194..7eaa53c 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -96,6 +96,7 @@ import { DesktopSideBar } from "../DesktopSideBar"; import { HubsIcon } from "../../assets/Icons/HubsIcon"; import { MessagingIcon } from "../../assets/Icons/MessagingIcon"; import { formatEmailDate } from "./QMailMessages"; +import { AdminSpace } from "../Chat/AdminSpace"; // let touchStartY = 0; // let disablePullToRefresh = false; @@ -2475,7 +2476,7 @@ export const Group = ({ handleNewEncryptionNotification={ setNewEncryptionNotification } - hide={groupSection !== "chat" || !secretKey} + hide={groupSection !== "chat" || !secretKey || selectedDirect || newChat} hideView={!(desktopViewMode === 'chat' && selectedGroup)} handleSecretKeyCreationInProgress={ handleSecretKeyCreationInProgress @@ -2588,6 +2589,8 @@ export const Group = ({ defaultThread={defaultThread} setDefaultThread={setDefaultThread} /> + )} diff --git a/src/qortalRequests/get.ts b/src/qortalRequests/get.ts index 74e3fe2..fa59def 100644 --- a/src/qortalRequests/get.ts +++ b/src/qortalRequests/get.ts @@ -20,6 +20,7 @@ import { } from "../background"; import { getNameInfo, uint8ArrayToObject } from "../backgroundFunctions/encryption"; import { showSaveFilePicker } from "../components/Apps/useQortalMessageListener"; +import { getPublishesFromAdminsAdminSpace } from "../components/Chat/AdminSpaceInner"; import { extractComponents } from "../components/Chat/MessageDisplay"; import { decryptResource, getGroupAdmins, getPublishesFromAdmins, validateSecretKey } from "../components/Group/Group"; import { QORT_DECIMALS } from "../constants/constants"; @@ -402,7 +403,7 @@ export const encryptData = async (data, sender) => { export const encryptQortalGroupData = async (data, sender) => { let data64 = data.data64; let groupId = data?.groupId - + let isAdmins = data?.isAdmins if(!groupId){ throw new Error('Please provide a groupId') } @@ -412,7 +413,10 @@ export const encryptQortalGroupData = async (data, sender) => { if (!data64) { throw new Error("Please include data to encrypt"); } + + let secretKeyObject + if(!isAdmins){ if(groupSecretkeys[groupId] && groupSecretkeys[groupId].secretKeyObject && groupSecretkeys[groupId]?.timestamp && (Date.now() - groupSecretkeys[groupId]?.timestamp) < 1200000){ secretKeyObject = groupSecretkeys[groupId].secretKeyObject } @@ -445,7 +449,44 @@ url timestamp: Date.now() } } +} else { + if(groupSecretkeys[`admins-${groupId}`] && groupSecretkeys[`admins-${groupId}`].secretKeyObject && groupSecretkeys[`admins-${groupId}`]?.timestamp && (Date.now() - groupSecretkeys[`admins-${groupId}`]?.timestamp) < 1200000){ + secretKeyObject = groupSecretkeys[`admins-${groupId}`].secretKeyObject + } + + if(!secretKeyObject){ + const { names } = + await getGroupAdmins(groupId) + + const publish = + await getPublishesFromAdminsAdminSpace(names, groupId); + if(publish === false) throw new Error('No group key found.') + const url = await createEndpoint(`/arbitrary/DOCUMENT_PRIVATE/${publish.name}/${ + publish.identifier + }?encoding=base64`); + + const res = await fetch( +url + ); + const resData = await res.text(); + const decryptedKey: any = await decryptResource(resData); + + const dataint8Array = base64ToUint8Array(decryptedKey.data); + const decryptedKeyToObject = uint8ArrayToObject(dataint8Array); + + if (!validateSecretKey(decryptedKeyToObject)) + throw new Error("SecretKey is not valid"); + secretKeyObject = decryptedKeyToObject + groupSecretkeys[`admins-${groupId}`] = { + secretKeyObject, + timestamp: Date.now() + } + } + + + +} const resGroupEncryptedResource = encryptSingle({ data64, secretKeyObject: secretKeyObject, @@ -461,17 +502,17 @@ url export const decryptQortalGroupData = async (data, sender) => { let data64 = data.data64; let groupId = data?.groupId + let isAdmins = data?.isAdmins if(!groupId){ throw new Error('Please provide a groupId') } - if (data.fileId) { - data64 = await getFileFromContentScript(data.fileId); - } + if (!data64) { throw new Error("Please include data to encrypt"); } let secretKeyObject + if(!isAdmins){ if(groupSecretkeys[groupId] && groupSecretkeys[groupId].secretKeyObject && groupSecretkeys[groupId]?.timestamp && (Date.now() - groupSecretkeys[groupId]?.timestamp) < 1200000){ secretKeyObject = groupSecretkeys[groupId].secretKeyObject } @@ -502,6 +543,40 @@ url timestamp: Date.now() } } +} else { + if(groupSecretkeys[`admins-${groupId}`] && groupSecretkeys[`admins-${groupId}`].secretKeyObject && groupSecretkeys[`admins-${groupId}`]?.timestamp && (Date.now() - groupSecretkeys[`admins-${groupId}`]?.timestamp) < 1200000){ + secretKeyObject = groupSecretkeys[`admins-${groupId}`].secretKeyObject + } + if(!secretKeyObject){ + const { names } = + await getGroupAdmins(groupId) + + const publish = + await getPublishesFromAdminsAdminSpace(names, groupId); + if(publish === false) throw new Error('No group key found.') + const url = await createEndpoint(`/arbitrary/DOCUMENT_PRIVATE/${publish.name}/${ + publish.identifier + }?encoding=base64`); + + const res = await fetch( +url + ); + const resData = await res.text(); + const decryptedKey: any = await decryptResource(resData); + + const dataint8Array = base64ToUint8Array(decryptedKey.data); + const decryptedKeyToObject = uint8ArrayToObject(dataint8Array); + if (!validateSecretKey(decryptedKeyToObject)) + throw new Error("SecretKey is not valid"); + secretKeyObject = decryptedKeyToObject + groupSecretkeys[`admins-${groupId}`] = { + secretKeyObject, + timestamp: Date.now() + } + } + + +} const resGroupDecryptResource = decryptSingle({ data64, secretKeyObject: secretKeyObject, skipDecodeBase64: true