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