diff --git a/src/App.tsx b/src/App.tsx index 1a151ea..2d9a709 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,7 +44,7 @@ import Info from "./assets/svgs/Info.svg"; import CloseIcon from "@mui/icons-material/Close"; import { FilePicker } from '@capawesome/capacitor-file-picker'; import './utils/seedPhrase/RandomSentenceGenerator'; - +import { useFetchResources } from "./common/useFetchResources"; import { createAccount, generateRandomSentence, @@ -348,6 +348,7 @@ export const isMainWindow = true; function App() { const [extState, setExtstate] = useState("not-authenticated"); const [desktopViewMode, setDesktopViewMode] = useState("home"); + const {downloadResource} = useFetchResources() const [backupjson, setBackupjson] = useState(null); const [rawWallet, setRawWallet] = useState(null); @@ -1682,7 +1683,8 @@ function App() { setOpenSnackGlobal: setOpenSnack, infoSnackCustom: infoSnack, setInfoSnackCustom: setInfoSnack, - userInfo: userInfo + userInfo: userInfo, + downloadResource }} > ({ get }) => { + const resources = get(resourceDownloadControllerAtom); + return resources[key] || null; // Return the value for the key or null if not found + }, +}); + +export const blobControllerAtom = atom({ + key: 'blobControllerAtom', + default: {}, +}); + +export const blobKeySelector = selectorFamily({ + key: 'blobKeySelector', + get: (key) => ({ get }) => { + const blobs = get(blobControllerAtom); + return blobs[key] || null; // Return the value for the key or null if not found + }, +}); diff --git a/src/background-cases.ts b/src/background-cases.ts index 15eb9b7..7f336fd 100644 --- a/src/background-cases.ts +++ b/src/background-cases.ts @@ -1220,6 +1220,7 @@ export async function encryptAndPublishSymmetricKeyGroupChatCase( }, event.origin ); + if (!previousData) { try { sendChatGroup({ groupId, @@ -1230,6 +1231,7 @@ export async function encryptAndPublishSymmetricKeyGroupChatCase( } catch (error) { // error in sending chat message } + } try { sendChatNotification(data, groupId, previousData, numberOfMembers); } catch (error) { diff --git a/src/background.ts b/src/background.ts index 73676c1..9b266eb 100644 --- a/src/background.ts +++ b/src/background.ts @@ -122,6 +122,9 @@ FilePicker.requestPermissions().then(permission => { }).catch((error)=> console.error(error));; +export let groupSecretkeys = {} + + export function cleanUrl(url) { return url?.replace(/^(https?:\/\/)?(www\.)?/, ""); @@ -2888,7 +2891,7 @@ function setupMessageListener() { // for announcement notification clearInterval(interval); } - + groupSecretkeys = {} const wallet = await getSaveWallet(); const address = wallet.address0; const key1 = `tempPublish-${address}`; diff --git a/src/common/useFetchResources.tsx b/src/common/useFetchResources.tsx new file mode 100644 index 0000000..2180966 --- /dev/null +++ b/src/common/useFetchResources.tsx @@ -0,0 +1,124 @@ +import React, { useCallback } from 'react'; +import { useRecoilState } from 'recoil'; +import { resourceDownloadControllerAtom } from '../atoms/global'; +import { getBaseApiReact } from '../App'; + +export const useFetchResources = () => { + const [resources, setResources] = useRecoilState(resourceDownloadControllerAtom); + + const downloadResource = useCallback(({ service, name, identifier }, build) => { + setResources((prev) => ({ + ...prev, + [`${service}-${name}-${identifier}`]: { + ...(prev[`${service}-${name}-${identifier}`] || {}), + service, + name, + identifier, + }, + })); + + try { + let isCalling = false; + let percentLoaded = 0; + let timer = 24; + let calledFirstTime = false + + const intervalId = setInterval(async () => { + if (isCalling) return; + isCalling = true; + + + + let res + + if(!build){ + const urlFirstTime = `${getBaseApiReact()}/arbitrary/resource/status/${service}/${name}/${identifier}`; + const resCall = await fetch(urlFirstTime, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + res = await resCall.json() + } + + + if(build || (calledFirstTime === false && res?.status !== 'READY')){ + const url = `${getBaseApiReact()}/arbitrary/resource/status/${service}/${name}/${identifier}?build=true`; + const resCall = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + res = await resCall.json(); + + } + calledFirstTime = true + isCalling = false; + + if (res.localChunkCount) { + if (res.percentLoaded) { + if (res.percentLoaded === percentLoaded && res.percentLoaded !== 100) { + timer = timer - 5; + } else { + timer = 24; + } + + if (timer < 0) { + timer = 24; + isCalling = true; + + // Update Recoil state for refetching + setResources((prev) => ({ + ...prev, + [`${service}-${name}-${identifier}`]: { + ...(prev[`${service}-${name}-${identifier}`] || {}), + status: { + ...res, + status: 'REFETCHING', + }, + }, + })); + + setTimeout(() => { + isCalling = false; + downloadResource({ name, service, identifier }, true); + }, 25000); + return; + } + + percentLoaded = res.percentLoaded; + } + + // Update Recoil state for progress + setResources((prev) => ({ + ...prev, + [`${service}-${name}-${identifier}`]: { + ...(prev[`${service}-${name}-${identifier}`] || {}), + status: res, + }, + })); + } + + // Check if progress is 100% and clear interval if true + if (res?.status === 'READY') { + clearInterval(intervalId); + + // Update Recoil state for completion + setResources((prev) => ({ + ...prev, + [`${service}-${name}-${identifier}`]: { + ...(prev[`${service}-${name}-${identifier}`] || {}), + status: res, + }, + })); + } + }, !calledFirstTime ? 100 :5000); + } catch (error) { + console.error('Error during resource fetch:', error); + } + }, [setResources]); + + return { downloadResource }; +}; diff --git a/src/components/Apps/AppViewerContainer.tsx b/src/components/Apps/AppViewerContainer.tsx index 51bc0ff..8622eca 100644 --- a/src/components/Apps/AppViewerContainer.tsx +++ b/src/components/Apps/AppViewerContainer.tsx @@ -3,7 +3,7 @@ import { AppViewer } from './AppViewer'; import Frame from 'react-frame-component'; import { MyContext, isMobile } from '../../App'; -const AppViewerContainer = React.forwardRef(({ app, isSelected, hide }, ref) => { +const AppViewerContainer = React.forwardRef(({ app, isSelected, hide, customHeight }, ref) => { const { rootHeight } = useContext(MyContext); @@ -36,7 +36,7 @@ const AppViewerContainer = React.forwardRef(({ app, isSelected, hide }, ref) => } style={{ display: (!isSelected || hide) && 'none', - height: !isMobile ? '100vh' : `calc(${rootHeight} - 60px - 45px)`, + height: customHeight ? customHeight : !isMobile ? '100vh' : `calc(${rootHeight} - 60px - 45px)`, border: 'none', width: '100%', overflow: 'hidden', diff --git a/src/components/Apps/useQortalMessageListener.tsx b/src/components/Apps/useQortalMessageListener.tsx index 4c647e3..91c7bde 100644 --- a/src/components/Apps/useQortalMessageListener.tsx +++ b/src/components/Apps/useQortalMessageListener.tsx @@ -77,9 +77,8 @@ export const saveFileInChunks = async ( isFirstChunk = false; } - console.log('File saved successfully in chunks:', fullFileName); } catch (error) { - console.error('Error saving file in chunks:', error); + throw error } }; @@ -184,7 +183,7 @@ const UIQortalRequests = [ 'GET_TX_ACTIVITY_SUMMARY', 'GET_FOREIGN_FEE', 'UPDATE_FOREIGN_FEE', 'GET_SERVER_CONNECTION_HISTORY', 'SET_CURRENT_FOREIGN_SERVER', 'ADD_FOREIGN_SERVER', 'REMOVE_FOREIGN_SERVER', 'GET_DAY_SUMMARY', 'CREATE_TRADE_BUY_ORDER', - 'CREATE_TRADE_SELL_ORDER', 'CANCEL_TRADE_SELL_ORDER', 'IS_USING_GATEWAY', 'ADMIN_ACTION', 'OPEN_NEW_TAB', 'CREATE_AND_COPY_EMBED_LINK' + 'CREATE_TRADE_SELL_ORDER', 'CANCEL_TRADE_SELL_ORDER', 'IS_USING_GATEWAY', 'ADMIN_ACTION', 'OPEN_NEW_TAB', 'CREATE_AND_COPY_EMBED_LINK', 'DECRYPT_QORTAL_GROUP_DATA' ]; @@ -282,6 +281,7 @@ const UIQortalRequests = [ setOpenSnackGlobal(true); await saveFileInChunks(blob, filename) + setInfoSnackCustom({ type: "success", message: @@ -479,7 +479,7 @@ isDOMContentLoaded: false } else if ( event?.data?.action === 'PUBLISH_MULTIPLE_QDN_RESOURCES' || event?.data?.action === 'PUBLISH_QDN_RESOURCE' || - event?.data?.action === 'ENCRYPT_DATA' + event?.data?.action === 'ENCRYPT_DATA' || event?.data?.action === 'ENCRYPT_DATA_WITH_SHARING_KEY' || 'ENCRYPT_QORTAL_GROUP_DATA' ) { let data; diff --git a/src/components/Chat/ChatGroup.tsx b/src/components/Chat/ChatGroup.tsx index b8fa7ee..dd33b75 100644 --- a/src/components/Chat/ChatGroup.tsx +++ b/src/components/Chat/ChatGroup.tsx @@ -15,7 +15,7 @@ import { CustomizedSnackbars } from '../Snackbar/Snackbar' import { PUBLIC_NOTIFICATION_CODE_FIRST_SECRET_KEY } from '../../constants/codes' import { useMessageQueue } from '../../MessageQueueContext' import { executeEvent } from '../../utils/events' -import { Box, ButtonBase, Typography } from '@mui/material' +import { Box, ButtonBase, Divider, Typography } from '@mui/material' import ShortUniqueId from "short-unique-id"; import { ReplyPreview } from './MessageItem' import { ExitIcon } from '../../assets/Icons/ExitIcon' @@ -25,6 +25,8 @@ import MentionList from './MentionList' import { ChatOptions } from './ChatOptions' import { isFocusedParentGroupAtom } from '../../atoms/global' import { useRecoilState } from 'recoil' +import AppViewerContainer from '../Apps/AppViewerContainer' +import CloseIcon from "@mui/icons-material/Close"; const uid = new ShortUniqueId({ length: 5 }); @@ -35,7 +37,7 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, const [isLoading, setIsLoading] = useState(false) const [messageSize, setMessageSize] = useState(0) const [onEditMessage, setOnEditMessage] = useState(null) - + const [isOpenQManager, setIsOpenQManager] = useState(null) const [isMoved, setIsMoved] = useState(false); const [openSnack, setOpenSnack] = React.useState(false); const [infoSnack, setInfoSnack] = React.useState(null); @@ -56,7 +58,9 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, const lastReadTimestamp = useRef(null) - + const openQManager = useCallback(()=> { + setIsOpenQManager(true) + }, []) const getTimestampEnterChat = async () => { try { return new Promise((res, rej) => { @@ -826,7 +830,8 @@ const sendMessage = async ()=> {
- {}} members={members} myName={myName} selectedGroup={selectedGroup}/> + {}} members={members} myName={myName} selectedGroup={selectedGroup}/>
@@ -897,7 +902,55 @@ const sendMessage = async ()=> { {` Send`} )} + {isOpenQManager !== null && ( + + + + Q-Manager + { + setIsOpenQManager(false) + }}> + + + + + + )} {isFocusedParent && messageSize > 750 && ( diff --git a/src/components/Chat/ChatOptions.tsx b/src/components/Chat/ChatOptions.tsx index 8717405..7f0eb87 100644 --- a/src/components/Chat/ChatOptions.tsx +++ b/src/components/Chat/ChatOptions.tsx @@ -32,6 +32,7 @@ import { formatTimestamp } from "../../utils/time"; import { ContextMenuMentions } from "../ContextMenuMentions"; import { convert } from 'html-to-text'; import { executeEvent } from "../../utils/events"; +import InsertLinkIcon from '@mui/icons-material/InsertLink'; const extractTextFromHTML = (htmlString = '') => { return convert(htmlString, { @@ -43,7 +44,7 @@ const cache = new CellMeasurerCache({ defaultHeight: 50, }); -export const ChatOptions = ({ messages, goToMessage, members, myName, selectedGroup }) => { +export const ChatOptions = ({ messages, goToMessage, members, myName, selectedGroup, openQManager }) => { const [mode, setMode] = useState("default"); const [searchValue, setSearchValue] = useState(""); const [selectedMember, setSelectedMember] = useState(0); @@ -712,6 +713,16 @@ export const ChatOptions = ({ messages, goToMessage, members, myName, selectedGr }} /> + { + setMode("default") + setSearchValue('') + setSelectedMember(0) + openQManager() + }}> + + ); diff --git a/src/components/Embeds/AttachmentEmbed.tsx b/src/components/Embeds/AttachmentEmbed.tsx new file mode 100644 index 0000000..b1290b2 --- /dev/null +++ b/src/components/Embeds/AttachmentEmbed.tsx @@ -0,0 +1,329 @@ +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { MyContext, getBaseApiReact } from "../../App"; +import { + Card, + CardContent, + CardHeader, + Typography, + RadioGroup, + Radio, + FormControlLabel, + Button, + Box, + ButtonBase, + Divider, + Dialog, + IconButton, + CircularProgress, +} from "@mui/material"; +import { base64ToBlobUrl } from "../../utils/fileReading"; +import { saveFileToDiskGeneric } from "../../utils/generateWallet/generateWallet"; +import AttachmentIcon from '@mui/icons-material/Attachment'; +import RefreshIcon from "@mui/icons-material/Refresh"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import { CustomLoader } from "../../common/CustomLoader"; +import { Spacer } from "../../common/Spacer"; +import { FileAttachmentContainer, FileAttachmentFont } from "./Embed-styles"; +import DownloadIcon from "@mui/icons-material/Download"; +import SaveIcon from '@mui/icons-material/Save'; +import { useSetRecoilState } from "recoil"; +import { blobControllerAtom } from "../../atoms/global"; + + +export const AttachmentCard = ({ + resourceData, + resourceDetails, + owner, + refresh, + openExternal, + external, + isLoadingParent, + errorMsg, + encryptionType, + setInfoSnack, + setOpenSnack + }) => { + + const [isOpen, setIsOpen] = useState(true); + const { downloadResource } = useContext(MyContext); + + const saveToDisk = async ()=> { + const { name, service, identifier } = resourceData; + + const url = `${getBaseApiReact()}/arbitrary/${service}/${name}/${identifier}`; + fetch(url) + .then(response => response.blob()) + .then(async blob => { + setOpenSnack(true) + setInfoSnack({ + type: "info", + message: + "Saving file...", + }); + await saveFileToDiskGeneric(blob, resourceData?.fileName) + setOpenSnack(true) + setInfoSnack({ + type: "success", + message: + "File saved in INTERNAL STORAGE, DOCUMENT folder.", + }); + }) + .catch(error => { + console.error("Error fetching the video:", error); + }); + } + + const saveToDiskEncrypted = async ()=> { + let blobUrl + try { + const { name, service, identifier,key } = resourceData; + + const url = `${getBaseApiReact()}/arbitrary/${service}/${name}/${identifier}?encoding=base64`; + const res = await fetch(url) + const data = await res.text(); + let decryptedData + try { + if(key && encryptionType === 'private'){ + decryptedData = await window.sendMessage( + "DECRYPT_DATA_WITH_SHARING_KEY", + + { + encryptedData: data, + key: decodeURIComponent(key), + } + + ); + } + if(encryptionType === 'group'){ + decryptedData = await window.sendMessage( + "DECRYPT_QORTAL_GROUP_DATA", + + { + data64: data, + groupId: 683, + } + + ); + } + } catch (error) { + throw new Error('Unable to decrypt') + } + + if (!decryptedData || decryptedData?.error) throw new Error("Could not decrypt data"); + blobUrl = base64ToBlobUrl(decryptedData, resourceData?.mimeType) + const response = await fetch(blobUrl); + const blob = await response.blob(); + setOpenSnack(true) + setInfoSnack({ + type: "info", + message: + "Saving file...", + }); + await saveFileToDiskGeneric(blob, resourceData?.fileName) + setOpenSnack(true) + setInfoSnack({ + type: "success", + message: + "File saved in INTERNAL STORAGE, DOCUMENT folder.", + }); + } catch (error) { + console.error(error) + } finally { + if(blobUrl){ + URL.revokeObjectURL(blobUrl); + } + + } + } + return ( + + + + + ATTACHMENT embed + + + + + + {external && ( + + + + )} + + + + + Created by {owner} + + + {encryptionType === 'private' ? "ENCRYPTED" : encryptionType === 'group' ? 'GROUP ENCRYPTED' : "Not encrypted"} + + + + + + + {isLoadingParent && isOpen && ( + + {" "} + {" "} + + )} + {errorMsg && ( + + {" "} + + {errorMsg} + {" "} + + )} + + + + + {resourceData?.fileName && ( + <> + {resourceData?.fileName} + + + )} + { + if(resourceDetails?.status?.status === 'READY'){ + if(encryptionType){ + saveToDiskEncrypted() + return + } + saveToDisk() + return + } + downloadResource(resourceData) + }}> + + + {!resourceDetails && ( + <> + + Download File + + + )} + {resourceDetails && resourceDetails?.status?.status !== 'READY' && ( + <> + + Downloading: {resourceDetails?.status?.percentLoaded || '0'}% + + + )} + {resourceDetails && resourceDetails?.status?.status === 'READY' && ( + <> + + Save to Disk + + + )} + + + + + {resourceDetails?.status?.status} + + + + ); + }; \ No newline at end of file diff --git a/src/components/Embeds/Embed-styles.tsx b/src/components/Embeds/Embed-styles.tsx new file mode 100644 index 0000000..b0b5482 --- /dev/null +++ b/src/components/Embeds/Embed-styles.tsx @@ -0,0 +1,18 @@ +import { Box, Typography, styled } from "@mui/material"; + +export const FileAttachmentContainer = styled(Box)(({ theme }) => ({ + display: "flex", + alignItems: "center", + padding: "5px 10px", + border: `1px solid ${theme.palette.text.primary}`, + width: "100%", + gap: '20px' + })); + + export const FileAttachmentFont = styled(Typography)(({ theme }) => ({ + fontSize: "20px", + letterSpacing: 0, + fontWeight: 400, + userSelect: "none", + whiteSpace: "nowrap", + })); \ No newline at end of file diff --git a/src/components/Embeds/Embed.tsx b/src/components/Embeds/Embed.tsx index c893554..b5d4c82 100644 --- a/src/components/Embeds/Embed.tsx +++ b/src/components/Embeds/Embed.tsx @@ -1,73 +1,20 @@ -import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; -import { MyContext, getBaseApiReact } from "../../App"; -import { - Card, - CardContent, - CardHeader, - Typography, - RadioGroup, - Radio, - FormControlLabel, - Button, - Box, - ButtonBase, - Divider, - Dialog, - IconButton, -} from "@mui/material"; -import { getNameInfo } from "../Group/Group"; -import { getFee } from "../../background"; -import { Spacer } from "../../common/Spacer"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { getBaseApiReact } from "../../App"; + + import { CustomizedSnackbars } from "../Snackbar/Snackbar"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import OpenInNewIcon from "@mui/icons-material/OpenInNew"; + import { extractComponents } from "../Chat/MessageDisplay"; import { executeEvent } from "../../utils/events"; -import { CustomLoader } from "../../common/CustomLoader"; -import PollIcon from "@mui/icons-material/Poll"; -import ImageIcon from "@mui/icons-material/Image"; -import CloseIcon from "@mui/icons-material/Close"; -function decodeHTMLEntities(str) { - const txt = document.createElement("textarea"); - txt.innerHTML = str; - return txt.value; -} +import { base64ToBlobUrl } from "../../utils/fileReading"; +import { useRecoilValue, useSetRecoilState } from "recoil"; +import { blobControllerAtom, blobKeySelector, resourceKeySelector } from "../../atoms/global"; +import { parseQortalLink } from "./embed-utils"; +import { PollCard } from "./PollEmbed"; +import { ImageCard } from "./ImageEmbed"; +import { AttachmentCard } from "./AttachmentEmbed"; -const parseQortalLink = (link) => { - const prefix = "qortal://use-embed/"; - if (!link.startsWith(prefix)) { - throw new Error("Invalid link format"); - } - - // Decode any HTML entities in the link - link = decodeHTMLEntities(link); - - // Separate the type and query string - const [typePart, queryPart] = link.slice(prefix.length).split("?"); - - // Ensure only the type is parsed - const type = typePart.split("/")[0].toUpperCase(); - - const params = {}; - if (queryPart) { - const queryPairs = queryPart.split("&"); - - queryPairs.forEach((pair) => { - const [key, value] = pair.split("="); - if (key && value) { - const decodedKey = decodeURIComponent(key.trim()); - const decodedValue = value.trim().replace( - /<\/?[^>]+(>|$)/g, - "" // Remove any HTML tags - ); - params[decodedKey] = decodedValue; - } - }); - } - - return { type, ...params }; -}; const getPoll = async (name) => { const pollName = name; const url = `${getBaseApiReact()}/polls/${pollName}`; @@ -110,7 +57,35 @@ export const Embed = ({ embedLink }) => { const [infoSnack, setInfoSnack] = useState(null); const [external, setExternal] = useState(null); const [imageUrl, setImageUrl] = useState(""); - const [parsedData, setParsedData] = useState(null) + const [parsedData, setParsedData] = useState(null); + const setBlobs = useSetRecoilState(blobControllerAtom); + + const resourceData = useMemo(()=> { + const parsedDataOnTheFly = parseQortalLink(embedLink); + if(parsedDataOnTheFly?.service && parsedDataOnTheFly?.name && parsedDataOnTheFly?.identifier){ + return { + service : parsedDataOnTheFly?.service, + name: parsedDataOnTheFly?.name, + identifier: parsedDataOnTheFly?.identifier, + fileName: parsedDataOnTheFly?.fileName ? decodeURIComponent(parsedDataOnTheFly?.fileName) : null, + mimeType: parsedDataOnTheFly?.mimeType ? decodeURIComponent(parsedDataOnTheFly?.mimeType) : null, + key: parsedDataOnTheFly?.key ? decodeURIComponent(parsedDataOnTheFly?.key) : null, + } + } else { + return null + } + }, [embedLink]) + + const keyIdentifier = useMemo(()=> { + + if(resourceData){ + return `${resourceData.service}-${resourceData.name}-${resourceData.identifier}` + } else { + return undefined + } + }, [resourceData]) + const blobUrl = useRecoilValue(blobKeySelector(keyIdentifier)); + const handlePoll = async (parsedData) => { try { setIsLoading(true); @@ -120,13 +95,7 @@ export const Embed = ({ embedLink }) => { throw new Error("Invalid poll embed link. Missing name."); const pollRes = await getPoll(parsedData.name); setPoll(pollRes); - if (parsedData?.ref) { - const res = extractComponents(decodeURIComponent(parsedData.ref)); - - if (res?.service && res?.name) { - setExternal(res); - } - } + } catch (error) { setErrorMsg(error?.message || "Invalid embed link"); } finally { @@ -134,49 +103,104 @@ export const Embed = ({ embedLink }) => { } }; - const getImage = async ({ identifier, name, service }) => { + const getImage = async ({ identifier, name, service }, key, parsedData) => { try { + if(blobUrl?.blobUrl){ + return blobUrl?.blobUrl + } let numberOfTries = 0; let imageFinalUrl = null; - + const tryToGetImageStatus = async () => { const urlStatus = `${getBaseApiReact()}/arbitrary/resource/status/${service}/${name}/${identifier}?build=true`; - + const responseStatus = await fetch(urlStatus, { method: "GET", headers: { "Content-Type": "application/json", }, }); - + const responseData = await responseStatus.json(); if (responseData?.status === "READY") { - imageFinalUrl = `${getBaseApiReact()}/arbitrary/${service}/${name}/${identifier}?async=true`; - - // If parsedData is used here, it must be defined somewhere - if (parsedData?.ref) { - const res = extractComponents(decodeURIComponent(parsedData.ref)); - if (res?.service && res?.name) { - setExternal(res); + if (parsedData?.encryptionType) { + const urlData = `${getBaseApiReact()}/arbitrary/${service}/${name}/${identifier}?encoding=base64`; + + const responseData = await fetch(urlData, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const data = await responseData.text(); + if (data) { + let decryptedData + try { + if(key && encryptionType === 'private'){ + decryptedData = await window.sendMessage( + "DECRYPT_DATA_WITH_SHARING_KEY", + + { + encryptedData: data, + key: decodeURIComponent(key), + } + + ); + } + if(encryptionType === 'group'){ + decryptedData = await window.sendMessage( + "DECRYPT_QORTAL_GROUP_DATA", + + { + data64: data, + groupId: 683, + } + + ); + } + } catch (error) { + throw new Error('Unable to decrypt') + } + + if (!decryptedData || decryptedData?.error) throw new Error("Could not decrypt data"); + imageFinalUrl = base64ToBlobUrl(decryptedData) + setBlobs((prev=> { + return { + ...prev, + [`${service}-${name}-${identifier}`]: { + blobUrl: imageFinalUrl, + timestamp: Date.now() + } + } + })) + } else { + throw new Error('No data for image') } - } + + } else { + imageFinalUrl = `${getBaseApiReact()}/arbitrary/${service}/${name}/${identifier}?async=true`; + + // If parsedData is used here, it must be defined somewhere + + } } }; - + // Retry logic while (!imageFinalUrl && numberOfTries < 3) { await tryToGetImageStatus(); if (!imageFinalUrl) { numberOfTries++; - await new Promise((res)=> { - setTimeout(()=> { - res(null) - }, 5000) - }) + await new Promise((res) => { + setTimeout(() => { + res(null); + }, 5000); + }); } } - + if (imageFinalUrl) { + return imageFinalUrl; } else { setErrorMsg( @@ -187,12 +211,11 @@ export const Embed = ({ embedLink }) => { } catch (error) { console.error("Error fetching image:", error); setErrorMsg( - "An unexpected error occurred while trying to download the image" + error?.error || error?.message || "An unexpected error occurred while trying to download the image" ); return null; } }; - const handleImage = async (parsedData) => { try { @@ -200,23 +223,37 @@ export const Embed = ({ embedLink }) => { setErrorMsg(""); if (!parsedData?.name || !parsedData?.service || !parsedData?.identifier) throw new Error("Invalid image embed link. Missing param."); - const image = await getImage({ + let image = await getImage({ name: parsedData.name, service: parsedData.service, identifier: parsedData?.identifier, - }); + }, parsedData?.key, parsedData); + setImageUrl(image); + } catch (error) { setErrorMsg(error?.message || "Invalid embed link"); } finally { setIsLoading(false); } }; + + const handleLink = () => { try { const parsedData = parseQortalLink(embedLink); - setParsedData(parsedData) + setParsedData(parsedData); const type = parsedData?.type; + try { + if (parsedData?.ref) { + const res = extractComponents(decodeURIComponent(parsedData.ref)); + if (res?.service && res?.name) { + setExternal(res); + } + } + } catch (error) { + + } switch (type) { case "POLL": { @@ -227,6 +264,10 @@ export const Embed = ({ embedLink }) => { setType("IMAGE"); break; + case "ATTACHMENT": + setType("ATTACHMENT"); + + break; default: break; } @@ -255,10 +296,28 @@ export const Embed = ({ embedLink }) => { hasFetched.current = true; }, [embedLink]); + + + const resourceDetails = useRecoilValue(resourceKeySelector(keyIdentifier)); + + const { parsedType, encryptionType } = useMemo(() => { + let parsedType; + let encryptionType = false; + try { + const parsedDataOnTheFly = parseQortalLink(embedLink); + if (parsedDataOnTheFly?.type) { + parsedType = parsedDataOnTheFly.type; + } + if (parsedDataOnTheFly?.encryptionType) { + encryptionType = parsedDataOnTheFly?.encryptionType + } + } catch (error) {} + return { parsedType, encryptionType }; + }, [embedLink]); + return (
- {!type && } - {type === "POLL" && ( + {parsedType === "POLL" && ( { errorMsg={errorMsg} /> )} - {type === 'IMAGE' && ( - + {parsedType === "IMAGE" && ( + + )} + {parsedType === 'ATTACHMENT' && ( + )} { ); }; -export const PollCard = ({ - poll, - setInfoSnack, - setOpenSnack, - refresh, - openExternal, - external, - isLoadingParent, - errorMsg, -}) => { - const [selectedOption, setSelectedOption] = useState(""); - const [ownerName, setOwnerName] = useState(""); - const [showResults, setShowResults] = useState(false); - const [isOpen, setIsOpen] = useState(false); - const { show, userInfo } = useContext(MyContext); - const [isLoadingSubmit, setIsLoadingSubmit] = useState(false); - const handleVote = async () => { - const fee = await getFee("VOTE_ON_POLL"); - - await show({ - message: `Do you accept this VOTE_ON_POLL transaction? POLLS are public!`, - publishFee: fee.fee + " QORT", - }); - setIsLoadingSubmit(true); - - window - .sendMessage( - "voteOnPoll", - { - pollName: poll?.info?.pollName, - optionIndex: +selectedOption, - }, - 60000 - ) - .then((response) => { - setIsLoadingSubmit(false); - if (response.error) { - setInfoSnack({ - type: "error", - message: response?.error || "Unable to vote.", - }); - setOpenSnack(true); - return; - } else { - setInfoSnack({ - type: "success", - message: - "Successfully voted. Please wait a couple minutes for the network to propogate the changes.", - }); - setOpenSnack(true); - } - }) - .catch((error) => { - setIsLoadingSubmit(false); - setInfoSnack({ - type: "error", - message: error?.message || "Unable to vote.", - }); - setOpenSnack(true); - }); - }; - - const getName = async (owner) => { - try { - const res = await getNameInfo(owner); - if (res) { - setOwnerName(res); - } - } catch (error) {} - }; - - useEffect(() => { - if (poll?.info?.owner) { - getName(poll.info.owner); - } - }, [poll?.info?.owner]); - return ( - - - - - POLL embed - - - - - - {external && ( - - - - )} - - - - - Created by {ownerName || poll?.info?.owner} - - - - - {!isOpen && !errorMsg && ( - <> - - - - )} - {isLoadingParent && isOpen && ( - - {" "} - {" "} - - )} - {errorMsg && ( - - {" "} - - {errorMsg} - {" "} - - )} - - - - - - - Options - - setSelectedOption(e.target.value)} - > - {poll?.info?.pollOptions?.map((option, index) => ( - - } - label={option?.optionName} - /> - ))} - - - - - {" "} - {`${poll?.votes?.totalVotes} ${ - poll?.votes?.totalVotes === 1 ? " vote" : " votes" - }`} - - - - - item?.voterPublicKey === userInfo?.publicKey - ) - ? "visible" - : "hidden", - }} - > - You've already voted. - - - {isLoadingSubmit && ( - - Is processing transaction, please wait... - - )} - { - setShowResults((prev) => !prev); - }} - > - {showResults ? "hide " : "show "} results - - - {showResults && } - - - ); -}; - -const PollResults = ({ votes }) => { - const maxVotes = Math.max( - ...votes?.voteCounts?.map((option) => option.voteCount) - ); - const options = votes?.voteCounts; - return ( - - {options - .sort((a, b) => b.voteCount - a.voteCount) // Sort options by votes (highest first) - .map((option, index) => ( - - - - {`${index + 1}. ${option.optionName}`} - - - {option.voteCount} votes - - - - - - - ))} - - ); -}; - -export const ImageCard = ({ - image, - fetchImage, - owner, - setInfoSnack, - setOpenSnack, - refresh, - openExternal, - external, - isLoadingParent, - errorMsg, -}) => { - const [isOpen, setIsOpen] = useState(false); - - useEffect(() => { - if (isOpen) { - fetchImage(); - } - }, [isOpen]); - - return ( - - - - - IMAGE embed - - - - - - {external && ( - - - - )} - - - - - Created by {owner} - - - Not encrypted - - - - - {!isOpen && !errorMsg && ( - <> - - - - )} - {isLoadingParent && isOpen && ( - - {" "} - {" "} - - )} - {errorMsg && ( - - {" "} - - {errorMsg} - {" "} - - )} - - - - - - - - - ); -}; -export function ImageViewer({ src, alt = "" }) { - const [isFullscreen, setIsFullscreen] = useState(false); - - const handleOpenFullscreen = () => setIsFullscreen(true); - const handleCloseFullscreen = () => setIsFullscreen(false); - - return ( - <> - {/* Image in container */} - - {alt} - - - {/* Fullscreen Viewer */} - - - {/* Close Button */} - - - - - {/* Fullscreen Image */} - {alt} - - - - ); - } \ No newline at end of file + + + diff --git a/src/components/Embeds/ImageEmbed.tsx b/src/components/Embeds/ImageEmbed.tsx new file mode 100644 index 0000000..d54e836 --- /dev/null +++ b/src/components/Embeds/ImageEmbed.tsx @@ -0,0 +1,264 @@ +import React, { useEffect, useState } from "react"; +import { + Card, + CardContent, + Typography, + + Box, + ButtonBase, + Divider, + Dialog, + IconButton, + +} from "@mui/material"; + +import RefreshIcon from "@mui/icons-material/Refresh"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import { CustomLoader } from "../../common/CustomLoader"; +import ImageIcon from "@mui/icons-material/Image"; +import CloseIcon from "@mui/icons-material/Close"; + +export const ImageCard = ({ + image, + fetchImage, + owner, + refresh, + openExternal, + external, + isLoadingParent, + errorMsg, + encryptionType, + }) => { + const [isOpen, setIsOpen] = useState(true); + const [height, setHeight] = useState('400px') + useEffect(() => { + if (isOpen) { + fetchImage(); + } + }, [isOpen]); + + useEffect(()=> { + if(errorMsg){ + setHeight('300px') + } + }, [errorMsg]) + + return ( + + + + + IMAGE embed + + + + + + {external && ( + + + + )} + + + + + Created by {owner} + + + {encryptionType === 'private' ? "ENCRYPTED" : encryptionType === 'group' ? 'GROUP ENCRYPTED' : "Not encrypted"} + + + + + + {isLoadingParent && isOpen && ( + + {" "} + {" "} + + )} + {errorMsg && ( + + {" "} + + {errorMsg} + {" "} + + )} + + + + + + + + + ); + }; + + export function ImageViewer({ src, alt = "" }) { + const [isFullscreen, setIsFullscreen] = useState(false); + + const handleOpenFullscreen = () => setIsFullscreen(true); + const handleCloseFullscreen = () => setIsFullscreen(false); + + return ( + <> + {/* Image in container */} + + {alt} + + + {/* Fullscreen Viewer */} + + + {/* Close Button */} + + + + + {/* Fullscreen Image */} + {alt} + + + + ); + } \ No newline at end of file diff --git a/src/components/Embeds/PollEmbed.tsx b/src/components/Embeds/PollEmbed.tsx new file mode 100644 index 0000000..60797d5 --- /dev/null +++ b/src/components/Embeds/PollEmbed.tsx @@ -0,0 +1,388 @@ +import React, { useContext, useEffect, useState } from "react"; +import { MyContext } from "../../App"; +import { + Card, + CardContent, + CardHeader, + Typography, + RadioGroup, + Radio, + FormControlLabel, + Button, + Box, + ButtonBase, + Divider, + +} from "@mui/material"; +import { getNameInfo } from "../Group/Group"; +import PollIcon from "@mui/icons-material/Poll"; +import { getFee } from "../../background"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { Spacer } from "../../common/Spacer"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; + + +export const PollCard = ({ + poll, + setInfoSnack, + setOpenSnack, + refresh, + openExternal, + external, + isLoadingParent, + errorMsg, + }) => { + const [selectedOption, setSelectedOption] = useState(""); + const [ownerName, setOwnerName] = useState(""); + const [showResults, setShowResults] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const { show, userInfo } = useContext(MyContext); + const [isLoadingSubmit, setIsLoadingSubmit] = useState(false); + const handleVote = async () => { + const fee = await getFee("VOTE_ON_POLL"); + + await show({ + message: `Do you accept this VOTE_ON_POLL transaction? POLLS are public!`, + publishFee: fee.fee + " QORT", + }); + setIsLoadingSubmit(true); + + window + .sendMessage( + "voteOnPoll", + { + pollName: poll?.info?.pollName, + optionIndex: +selectedOption, + }, + 60000 + ) + .then((response) => { + setIsLoadingSubmit(false); + if (response.error) { + setInfoSnack({ + type: "error", + message: response?.error || "Unable to vote.", + }); + setOpenSnack(true); + return; + } else { + setInfoSnack({ + type: "success", + message: + "Successfully voted. Please wait a couple minutes for the network to propogate the changes.", + }); + setOpenSnack(true); + } + }) + .catch((error) => { + setIsLoadingSubmit(false); + setInfoSnack({ + type: "error", + message: error?.message || "Unable to vote.", + }); + setOpenSnack(true); + }); + }; + + const getName = async (owner) => { + try { + const res = await getNameInfo(owner); + if (res) { + setOwnerName(res); + } + } catch (error) {} + }; + + useEffect(() => { + if (poll?.info?.owner) { + getName(poll.info.owner); + } + }, [poll?.info?.owner]); + + return ( + + + + + POLL embed + + + + + + {external && ( + + + + )} + + + + + Created by {ownerName || poll?.info?.owner} + + + + + {!isOpen && !errorMsg && ( + <> + + + + )} + {isLoadingParent && isOpen && ( + + {" "} + {" "} + + )} + {errorMsg && ( + + {" "} + + {errorMsg} + {" "} + + )} + + + + + + + Options + + setSelectedOption(e.target.value)} + > + {poll?.info?.pollOptions?.map((option, index) => ( + + } + label={option?.optionName} + /> + ))} + + + + + {" "} + {`${poll?.votes?.totalVotes} ${ + poll?.votes?.totalVotes === 1 ? " vote" : " votes" + }`} + + + + + item?.voterPublicKey === userInfo?.publicKey + ) + ? "visible" + : "hidden", + }} + > + You've already voted. + + + {isLoadingSubmit && ( + + Is processing transaction, please wait... + + )} + { + setShowResults((prev) => !prev); + }} + > + {showResults ? "hide " : "show "} results + + + {showResults && } + + + ); + }; + + const PollResults = ({ votes }) => { + const maxVotes = Math.max( + ...votes?.voteCounts?.map((option) => option.voteCount) + ); + const options = votes?.voteCounts; + return ( + + {options + .sort((a, b) => b.voteCount - a.voteCount) // Sort options by votes (highest first) + .map((option, index) => ( + + + + {`${index + 1}. ${option.optionName}`} + + + {option.voteCount} votes + + + + + + + ))} + + ); + }; \ No newline at end of file diff --git a/src/components/Embeds/embed-utils.ts b/src/components/Embeds/embed-utils.ts new file mode 100644 index 0000000..c0fe9b0 --- /dev/null +++ b/src/components/Embeds/embed-utils.ts @@ -0,0 +1,40 @@ +function decodeHTMLEntities(str) { + const txt = document.createElement("textarea"); + txt.innerHTML = str; + return txt.value; + } + + export const parseQortalLink = (link) => { + const prefix = "qortal://use-embed/"; + if (!link.startsWith(prefix)) { + throw new Error("Invalid link format"); + } + + // Decode any HTML entities in the link + link = decodeHTMLEntities(link); + + // Separate the type and query string + const [typePart, queryPart] = link.slice(prefix.length).split("?"); + + // Ensure only the type is parsed + const type = typePart.split("/")[0].toUpperCase(); + + const params = {}; + if (queryPart) { + const queryPairs = queryPart.split("&"); + + queryPairs.forEach((pair) => { + const [key, value] = pair.split("="); + if (key && value) { + const decodedKey = decodeURIComponent(key.trim()); + const decodedValue = value.trim().replace( + /<\/?[^>]+(>|$)/g, + "" // Remove any HTML tags + ); + params[decodedKey] = decodedValue; + } + }); + } + + return { type, ...params }; + }; \ No newline at end of file diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index fc6a320..cebb71d 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -115,6 +115,38 @@ import { useHandleMobileNativeBack } from "../../hooks/useHandleMobileNativeBack // } // }); + +export const getPublishesFromAdmins = async (admins: string[], groupId) => { + // const validApi = await findUsableApi(); + const queryString = admins.map((name) => `name=${name}`).join("&"); + const url = `${getBaseApiReact()}${getArbitraryEndpointReact()}?mode=ALL&service=DOCUMENT_PRIVATE&identifier=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 === `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]; +}; + interface GroupProps { myAddress: string; isFocused: boolean; @@ -679,36 +711,7 @@ export const Group = ({ // }; // }, [checkGroupListFunc, myAddress]); - const getPublishesFromAdmins = async (admins: string[]) => { - // const validApi = await findUsableApi(); - const queryString = admins.map((name) => `name=${name}`).join("&"); - const url = `${getBaseApiReact()}${getArbitraryEndpointReact()}?mode=ALL&service=DOCUMENT_PRIVATE&identifier=symmetric-qchat-group-${ - selectedGroup?.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 === `symmetric-qchat-group-${selectedGroup?.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]; - }; + const getSecretKey = async ( loadingGroupParam?: boolean, secretKeyToPublish?: boolean @@ -757,7 +760,7 @@ export const Group = ({ throw new Error("Network error"); } const publish = - publishFromStorage || (await getPublishesFromAdmins(names)); + publishFromStorage || (await getPublishesFromAdmins(names, selectedGroup?.groupId)); if (prevGroupId !== selectedGroupRef.current.groupId) { if (settimeoutForRefetchSecretKey.current) { @@ -2306,6 +2309,7 @@ export const Group = ({ setNewEncryptionNotification } hide={groupSection !== "chat" || !secretKey} + handleSecretKeyCreationInProgress={ handleSecretKeyCreationInProgress } diff --git a/src/components/Snackbar/Snackbar.tsx b/src/components/Snackbar/Snackbar.tsx index 3e5fccb..dc1d61e 100644 --- a/src/components/Snackbar/Snackbar.tsx +++ b/src/components/Snackbar/Snackbar.tsx @@ -22,7 +22,9 @@ export const CustomizedSnackbars = ({open, setOpen, info, setInfo, duration}) = if(!open) return null return (
- + { }; -export const encryptDataGroup = ({ data64, publicKeys, privateKey, userPublicKey }: any) => { +export const encryptDataGroup = ({ data64, publicKeys, privateKey, userPublicKey, customSymmetricKey }: any) => { let combinedPublicKeys = [...publicKeys, userPublicKey] const decodedPrivateKey = Base58.decode(privateKey) @@ -76,9 +76,15 @@ export const encryptDataGroup = ({ data64, publicKeys, privateKey, userPublicKey throw new Error("The Uint8ArrayData you've submitted is invalid") } try { - // Generate a random symmetric key for the message. - const messageKey = new Uint8Array(32) + let messageKey + if(customSymmetricKey){ + messageKey = base64ToUint8Array(customSymmetricKey) + } else { + messageKey = new Uint8Array(32) crypto.getRandomValues(messageKey) + } + + if(!messageKey) throw new Error('Cannot create symmetric key') const nonce = new Uint8Array(24) crypto.getRandomValues(nonce) // Encrypt the data with the symmetric key. @@ -420,4 +426,44 @@ export function decryptDeprecatedSingle(uint8Array, publicKey, privateKey) { throw new Error("Unable to decrypt") } return uint8ArrayToBase64(_decryptedData) -} \ No newline at end of file +} + +export const decryptGroupEncryptionWithSharingKey = async ({ data64EncryptedData, key }: any) => { + + 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 + + // 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) + const symmetricKey = base64ToUint8Array(key); + + // Decrypt the data using the nonce and messageKey + const decryptedData = nacl.secretbox.open(encryptedData, nonce, symmetricKey) + + + // Check if decryption was successful + if (!decryptedData) { + throw new Error("Decryption failed"); + } + // Convert the decrypted Uint8Array back to a Base64 string + return uint8ArrayToBase64(decryptedData); + }; \ No newline at end of file diff --git a/src/qortalRequests.ts b/src/qortalRequests.ts index b838aa8..66ffee6 100644 --- a/src/qortalRequests.ts +++ b/src/qortalRequests.ts @@ -1,5 +1,5 @@ import { gateways, getApiKeyFromStorage } from "./background"; -import { addForeignServer, addListItems, adminAction, cancelSellOrder, createAndCopyEmbedLink, createBuyOrder, createPoll, decryptData, deleteListItems, deployAt, encryptData, getCrossChainServerInfo, getDaySummary, getForeignFee, getListItems, getServerConnectionHistory, getTxActivitySummary, getUserAccount, getUserWallet, getUserWalletInfo, getWalletBalance, joinGroup, openNewTab, publishMultipleQDNResources, publishQDNResource, removeForeignServer, saveFile, sendChatMessage, sendCoin, setCurrentForeignServer, updateForeignFee, voteOnPoll } from "./qortalRequests/get"; +import { addForeignServer, addListItems, adminAction, cancelSellOrder, createAndCopyEmbedLink, createBuyOrder, createPoll, decryptData, decryptDataWithSharingKey, decryptQortalGroupData, deleteListItems, deployAt, encryptData, encryptDataWithSharingKey, encryptQortalGroupData, getCrossChainServerInfo, getDaySummary, getForeignFee, getListItems, getServerConnectionHistory, getTxActivitySummary, getUserAccount, getUserWallet, getUserWalletInfo, getWalletBalance, joinGroup, openNewTab, publishMultipleQDNResources, publishQDNResource, removeForeignServer, saveFile, sendChatMessage, sendCoin, setCurrentForeignServer, updateForeignFee, voteOnPoll } from "./qortalRequests/get"; import { getData, storeData } from "./utils/chromeStorage"; @@ -713,7 +713,84 @@ export const isRunningGateway = async ()=> { } break; } - + + case "ENCRYPT_QORTAL_GROUP_DATA": { + try { + const res = await encryptQortalGroupData(request.payload, event.source); + event.source.postMessage({ + requestId: request.requestId, + action: request.action, + payload: res, + type: "backgroundMessageResponse", + }, event.origin); + } catch (error) { + event.source.postMessage({ + requestId: request.requestId, + action: request.action, + error: error?.message, + type: "backgroundMessageResponse", + }, event.origin); + } + break; + } + case "DECRYPT_QORTAL_GROUP_DATA": { + try { + const res = await decryptQortalGroupData(request.payload, event.source); + event.source.postMessage({ + requestId: request.requestId, + action: request.action, + payload: res, + type: "backgroundMessageResponse", + }, event.origin); + } catch (error) { + event.source.postMessage({ + requestId: request.requestId, + action: request.action, + error: error?.message, + type: "backgroundMessageResponse", + }, event.origin); + } + break; + } + case "ENCRYPT_DATA_WITH_SHARING_KEY": { + try { + const res = await encryptDataWithSharingKey(request.payload, isFromExtension) + event.source.postMessage({ + requestId: request.requestId, + action: request.action, + payload: res, + type: "backgroundMessageResponse", + }, event.origin); + } catch (error) { + event.source.postMessage({ + requestId: request.requestId, + action: request.action, + error: error?.message, + type: "backgroundMessageResponse", + }, event.origin); + } + break; + } + + case "DECRYPT_DATA_WITH_SHARING_KEY": { + try { + const res = await decryptDataWithSharingKey(request.payload, isFromExtension) + event.source.postMessage({ + requestId: request.requestId, + action: request.action, + payload: res, + type: "backgroundMessageResponse", + }, event.origin); + } catch (error) { + event.source.postMessage({ + requestId: request.requestId, + action: request.action, + error: error?.message, + type: "backgroundMessageResponse", + }, event.origin); + } + break; + } default: break; } diff --git a/src/qortalRequests/get.ts b/src/qortalRequests/get.ts index 7617e3a..bc969fa 100644 --- a/src/qortalRequests/get.ts +++ b/src/qortalRequests/get.ts @@ -14,17 +14,23 @@ import { sendCoin as sendCoinFunc, isUsingLocal, createBuyOrderTx, - performPowTask + performPowTask, + groupSecretkeys } from "../background"; -import { getNameInfo } from "../backgroundFunctions/encryption"; +import { getNameInfo, uint8ArrayToObject } from "../backgroundFunctions/encryption"; import { showSaveFilePicker } from "../components/Apps/useQortalMessageListener"; import { QORT_DECIMALS } from "../constants/constants"; import Base58 from "../deps/Base58"; import { base64ToUint8Array, + createSymmetricKeyAndNonce, decryptDeprecatedSingle, decryptGroupDataQortalRequest, + decryptGroupEncryptionWithSharingKey, + decryptSingle, encryptDataGroup, + encryptSingle, + objectToBase64, uint8ArrayStartsWith, uint8ArrayToBase64, } from "../qdn/encryption/group-encryption"; @@ -37,6 +43,7 @@ import DeleteTradeOffer from "../transactions/TradeBotDeleteRequest"; import signTradeBotTransaction from "../transactions/signTradeBotTransaction"; import { executeEvent } from "../utils/events"; import { extractComponents } from "../components/Chat/MessageDisplay"; +import { decryptResource, getGroupAdmins, getPublishesFromAdmins, validateSecretKey } from "../components/Group/Group"; const btcFeePerByte = 0.00000100 const ltcFeePerByte = 0.00000030 @@ -374,6 +381,168 @@ export const encryptData = async (data, sender) => { throw new Error("Unable to encrypt"); } }; + +export const encryptQortalGroupData = async (data, sender) => { + let data64 = data.data64; + let groupId = data?.groupId + + 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(groupSecretkeys[groupId] && groupSecretkeys[groupId].secretKeyObject && groupSecretkeys[groupId]?.timestamp && (Date.now() - groupSecretkeys[groupId]?.timestamp) < 1200000){ + secretKeyObject = groupSecretkeys[groupId].secretKeyObject + } + + if(!secretKeyObject){ + const { names } = + await getGroupAdmins(groupId) + + const publish = + await getPublishesFromAdmins(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[groupId] = { + secretKeyObject, + timestamp: Date.now() + } + } + + + const resGroupEncryptedResource = encryptSingle({ + data64, secretKeyObject: secretKeyObject, + }) + + if (resGroupEncryptedResource) { + return resGroupEncryptedResource; + } else { + throw new Error("Unable to encrypt"); + } +}; + +export const decryptQortalGroupData = async (data, sender) => { + let data64 = data.data64; + let groupId = data?.groupId + 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(groupSecretkeys[groupId] && groupSecretkeys[groupId].secretKeyObject && groupSecretkeys[groupId]?.timestamp && (Date.now() - groupSecretkeys[groupId]?.timestamp) < 1200000){ + secretKeyObject = groupSecretkeys[groupId].secretKeyObject + } + if(!secretKeyObject){ + const { names } = + await getGroupAdmins(groupId) + + const publish = + await getPublishesFromAdmins(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[groupId] = { + secretKeyObject, + timestamp: Date.now() + } + } + + const resGroupDecryptResource = decryptSingle({ + data64, secretKeyObject: secretKeyObject, skipDecodeBase64: true + }) + if (resGroupDecryptResource) { + return resGroupDecryptResource; + } else { + throw new Error("Unable to decrypt"); + } +}; + +export const encryptDataWithSharingKey = async (data, sender) => { + let data64 = data.data64; + let publicKeys = data.publicKeys || []; + if (data.fileId) { + data64 = await getFileFromContentScript(data.fileId); + } + if (!data64) { + throw new Error("Please include data to encrypt"); + } + const symmetricKey = createSymmetricKeyAndNonce() + const dataObject = { + data: data64, + key:symmetricKey.messageKey + } + const dataObjectBase64 = await objectToBase64(dataObject) + + const resKeyPair = await getKeyPair(); + const parsedData = resKeyPair; + const privateKey = parsedData.privateKey; + const userPublicKey = parsedData.publicKey; + + const encryptDataResponse = encryptDataGroup({ + data64: dataObjectBase64, + publicKeys: publicKeys, + privateKey, + userPublicKey, + customSymmetricKey: symmetricKey.messageKey + }); + if (encryptDataResponse) { + return encryptDataResponse; + } else { + throw new Error("Unable to encrypt"); + } +}; + +export const decryptDataWithSharingKey = async (data, sender) => { + const { encryptedData, key } = data; + + + if (!encryptedData) { + throw new Error("Please include data to decrypt"); + } + const decryptedData = await decryptGroupEncryptionWithSharingKey({data64EncryptedData: encryptedData, key}) + const base64ToObject = JSON.parse(atob(decryptedData)) + if(!base64ToObject.data) throw new Error('No data in the encrypted resource') + return base64ToObject.data +}; export const decryptData = async (data) => { const { encryptedData, publicKey } = data; @@ -579,7 +748,11 @@ export const deleteListItems = async (data, isFromExtension) => { } }; -export const publishQDNResource = async (data: any, sender, isFromExtension) => { +export const publishQDNResource = async ( + data: any, + sender, + isFromExtension +) => { const requiredFields = ["service"]; const missingFields: string[] = []; requiredFields.forEach((field) => { @@ -613,6 +786,9 @@ export const publishQDNResource = async (data: any, sender, isFromExtension) => if (data.identifier == null) { identifier = "default"; } + if (data.fileId) { + data64 = await getFileFromContentScript(data.fileId); + } if ( data.encrypt && (!data.publicKeys || @@ -620,23 +796,19 @@ export const publishQDNResource = async (data: any, sender, isFromExtension) => ) { throw new Error("Encrypting data requires public keys"); } - if (!data.encrypt && data.service.endsWith("_PRIVATE")) { - throw new Error("Only encrypted data can go into private services"); - } - if (data.fileId) { - data64 = await getFileFromContentScript(data.fileId); - } + + if (data.encrypt) { try { - const resKeyPair = await getKeyPair() - const parsedData = resKeyPair - const privateKey = parsedData.privateKey - const userPublicKey = parsedData.publicKey + const resKeyPair = await getKeyPair(); + const parsedData = resKeyPair; + const privateKey = parsedData.privateKey; + const userPublicKey = parsedData.publicKey; const encryptDataResponse = encryptDataGroup({ data64, publicKeys: data.publicKeys, privateKey, - userPublicKey + userPublicKey, }); if (encryptDataResponse) { data64 = encryptDataResponse; @@ -650,13 +822,16 @@ export const publishQDNResource = async (data: any, sender, isFromExtension) => const fee = await getFee("ARBITRARY"); - const resPermission = await getUserPermission({ - text1: "Do you give this application permission to publish to QDN?", - text2: `service: ${service}`, - text3: `identifier: ${identifier || null}`, - highlightedText: `isEncrypted: ${!!data.encrypt}`, - fee: fee.fee, - }, isFromExtension); + const resPermission = await getUserPermission( + { + text1: "Do you give this application permission to publish to QDN?", + text2: `service: ${service}`, + text3: `identifier: ${identifier || null}`, + highlightedText: data?.externalEncrypt ? `App is externally encrypting the resource. Make sure you trust the app.` : `isEncrypted: ${!!data.encrypt}`, + fee: fee.fee, + }, + isFromExtension + ); const { accepted } = resPermission; if (accepted) { @@ -689,6 +864,7 @@ export const publishQDNResource = async (data: any, sender, isFromExtension) => } }; + export const publishMultipleQDNResources = async (data: any, sender, isFromExtension) => { const requiredFields = ["resources"]; const missingFields: string[] = []; @@ -2897,7 +3073,17 @@ const missingFieldsFunc = (data, requiredFields)=> { } const encode = (value) => encodeURIComponent(value.trim()); // Helper to encode values - +const buildQueryParams = (data) => { +const allowedParams= ["name", "service", "identifier", "mimeType", "fileName", "encryptionType", "key"] + return Object.entries(data) + .map(([key, value]) => { + if (value === undefined || value === null || value === false || !allowedParams.includes(key)) return null; // Skip null, undefined, or false + if (typeof value === "boolean") return `${key}=${value}`; // Handle boolean values + return `${key}=${encode(value)}`; // Encode other values + }) + .filter(Boolean) // Remove null values + .join("&"); // Join with `&` +}; export const createAndCopyEmbedLink = async (data, isFromExtension) => { const requiredFields = [ "type", @@ -2929,53 +3115,33 @@ export const createAndCopyEmbedLink = async (data, isFromExtension) => { .filter(Boolean) // Remove null values .join("&"); // Join with `&` const link = `qortal://use-embed/POLL?${queryParams}` - - navigator.clipboard.writeText(link) - .then(() => { - executeEvent('openGlobalSnackBar', { - message: 'Copied link to clipboard', - type: 'info' - }) - //success - }) - .catch((error) => { - executeEvent('openGlobalSnackBar', { - message: 'Failed to copy to clipboard', - type: 'error' - }) - // error - }); + try { + await navigator.clipboard.writeText(link); + } catch (error) { + throw new Error('Failed to copy to clipboard.') + } return link; } - case "IMAGE": { + case "IMAGE": + case "ATTACHMENT": + { missingFieldsFunc(data, [ "type", "name", "service", "identifier" ]) - const queryParams = [ - `name=${encode(data.name)}`, - `service=${encode(data.service)}`, - `identifier=${encode(data.identifier)}`, - data.ref ? `ref=${encode(data.ref)}` : null, // Add only if ref exists - ] - .filter(Boolean) // Remove null values - .join("&"); // Join with `&` + if(data?.encryptionType === 'private' && !data?.key){ + throw new Error('For an encrypted resource, you must provide the key to create the shared link') + } + const queryParams = buildQueryParams(data) - const link = `qortal://use-embed/IMAGE?${queryParams}`; + const link = `qortal://use-embed/${data.type}?${queryParams}`; try { await navigator.clipboard.writeText(link); - executeEvent("openGlobalSnackBar", { - message: "Copied link to clipboard", - type: "info", - }); } catch (error) { - executeEvent("openGlobalSnackBar", { - message: "Failed to copy to clipboard", - type: "error", - }); + throw new Error('Failed to copy to clipboard.') } return link; diff --git a/src/utils/fileReading/index.ts b/src/utils/fileReading/index.ts index c96dd74..a72f785 100644 --- a/src/utils/fileReading/index.ts +++ b/src/utils/fileReading/index.ts @@ -54,4 +54,14 @@ export const fileToBase64 = (file) => new Promise(async (resolve, reject) => { reject(error) semaphore.release() } -}) \ No newline at end of file +}) + +export const base64ToBlobUrl = (base64, mimeType = "image/png") => { + const binary = atob(base64); + const array = []; + for (let i = 0; i < binary.length; i++) { + array.push(binary.charCodeAt(i)); + } + const blob = new Blob([new Uint8Array(array)], { type: mimeType }); + return URL.createObjectURL(blob); + }; \ No newline at end of file diff --git a/src/utils/generateWallet/generateWallet.ts b/src/utils/generateWallet/generateWallet.ts index fb183bf..c3f90ae 100644 --- a/src/utils/generateWallet/generateWallet.ts +++ b/src/utils/generateWallet/generateWallet.ts @@ -1,7 +1,9 @@ // @ts-nocheck +import { saveFileInChunks, showSaveFilePicker } from '../../components/Apps/useQortalMessageListener'; import { crypto, walletVersion } from '../../constants/decryptWallet'; import { doInitWorkers, kdf } from '../../deps/kdf'; +import { mimeToExtensionMap } from '../memeTypes'; import PhraseWallet from './phrase-wallet'; import * as WORDLISTS from './wordlists'; import { Filesystem, Directory, Encoding } from '@capacitor/filesystem'; @@ -109,4 +111,21 @@ export const saveSeedPhraseToDisk = async (data) => { encoding: Encoding.UTF8, }); +} + +const hasExtension = (filename) => { + return filename.includes(".") && filename.split(".").pop().length > 0; + }; + + +export const saveFileToDiskGeneric = async (blob, filename) => { + const timestamp = new Date() + .toISOString() + .replace(/:/g, "-"); // Safe timestamp for filenames + + const fileExtension = mimeToExtensionMap[blob.type] +let fileName = filename || "qortal_file_" + timestamp + "." + fileExtension; +fileName = hasExtension(fileName) ? fileName : fileName + "." + fileExtension; +await saveFileInChunks(blob, fileName) +// await FileSaver.saveAs(blob, fileName); } \ No newline at end of file