diff --git a/src/App.tsx b/src/App.tsx index 92e0965..0104099 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -766,6 +766,24 @@ function App() { balanceSetInterval(); }); }; + + const refetchUserInfo = () => { + window + .sendMessage('userInfo') + .then((response) => { + if (response && !response.error) { + setUserInfo(response); + } + }) + .catch((error) => { + console.error('Failed to get user info:', error); + }); + }; + + const getBalanceAndUserInfoFunc = () => { + getBalanceFunc(); + refetchUserInfo(); + }; const getLtcBalanceFunc = () => { setLtcBalanceLoading(true); window @@ -1503,7 +1521,7 @@ function App() { 0) { - return nameData[0].name; + if (nameData?.name) { + return nameData.name; } else { return ''; } } export async function getNameInfoForOthers(address) { + if (!address) return ''; const validApi = await getBaseApi(); - const response = await fetch(validApi + '/names/address/' + address); + const response = await fetch(validApi + '/names/primary/' + address); const nameData = await response.json(); - if (nameData?.length > 0) { - return nameData[0].name; + if (nameData?.name) { + return nameData?.name; } else { return ''; } diff --git a/src/components/Apps/AppPublish.tsx b/src/components/Apps/AppPublish.tsx index fbea9c9..49b0f1f 100644 --- a/src/components/Apps/AppPublish.tsx +++ b/src/components/Apps/AppPublish.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import { AppLibrarySubTitle, AppPublishTagsContainer, @@ -25,6 +25,7 @@ import { CustomizedSnackbars } from '../Snackbar/Snackbar'; import { getFee } from '../../background/background.ts'; import { fileToBase64 } from '../../utils/fileReading'; import { useTranslation } from 'react-i18next'; +import { useSortedMyNames } from '../../hooks/useSortedMyNames'; const CustomSelect = styled(Select)({ border: '0.5px solid var(--50-white, #FFFFFF80)', @@ -58,7 +59,8 @@ const CustomMenuItem = styled(MenuItem)({ // }, }); -export const AppPublish = ({ names, categories }) => { +export const AppPublish = ({ categories, myAddress, myName }) => { + const [names, setNames] = useState([]); const [name, setName] = useState(''); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); @@ -148,6 +150,27 @@ export const AppPublish = ({ names, categories }) => { getQapp(name, appType); }, [name, appType]); + const getNames = useCallback(async () => { + if (!myAddress) return; + try { + setIsLoading('Loading names'); + const res = await fetch( + `${getBaseApiReact()}/names/address/${myAddress}?limit=0` + ); + const data = await res.json(); + setNames(data?.map((item) => item.name)); + } catch (error) { + console.error(error); + } finally { + setIsLoading(''); + } + }, [myAddress]); + useEffect(() => { + getNames(); + }, [getNames]); + + const mySortedNames = useSortedMyNames(names, myName); + const publishApp = async () => { try { const data = { @@ -194,13 +217,13 @@ export const AppPublish = ({ names, categories }) => { postProcess: 'capitalizeFirstChar', }) ); - const fileBase64 = await fileToBase64(file); await new Promise((res, rej) => { window .sendMessage('publishOnQDN', { - data: fileBase64, + data: file, service: appType, title, + name, description, category, tag1, @@ -317,7 +340,7 @@ export const AppPublish = ({ names, categories }) => { {/* This is the placeholder item */} - {names.map((name) => { + {mySortedNames.map((name) => { return {name}; })} diff --git a/src/components/Apps/AppViewer.tsx b/src/components/Apps/AppViewer.tsx index 75b468b..0dd695d 100644 --- a/src/components/Apps/AppViewer.tsx +++ b/src/components/Apps/AppViewer.tsx @@ -94,7 +94,7 @@ export const AppViewer = forwardRef( useEffect(() => { const iframe = iframeRef?.current; - if (!iframe) return; + if (!iframe || !iframe?.src) return; try { const targetOrigin = new URL(iframe.src).origin; @@ -109,7 +109,7 @@ export const AppViewer = forwardRef( useEffect(() => { const iframe = iframeRef?.current; - if (!iframe) return; + if (!iframe || !iframe?.src) return; try { const targetOrigin = new URL(iframe.src).origin; diff --git a/src/components/Apps/AppsDesktop.tsx b/src/components/Apps/AppsDesktop.tsx index 37d7648..805c2c3 100644 --- a/src/components/Apps/AppsDesktop.tsx +++ b/src/components/Apps/AppsDesktop.tsx @@ -40,6 +40,7 @@ export const AppsDesktop = ({ hasUnreadGroups, setDesktopViewMode, desktopViewMode, + myAddress, }) => { const [availableQapps, setAvailableQapps] = useState([]); const [selectedAppInfo, setSelectedAppInfo] = useState(null); @@ -485,6 +486,7 @@ export const AppsDesktop = ({ setMode={setMode} myApp={myApp} myWebsite={myWebsite} + myAddress={myAddress} /> )} @@ -515,7 +517,11 @@ export const AppsDesktop = ({ /> {mode === 'publish' && !selectedTab && ( - + )} {tabs.map((tab) => { @@ -552,6 +558,7 @@ export const AppsDesktop = ({ myApp={myApp} myName={myName} myWebsite={myWebsite} + myAddress={myAddress} setMode={setMode} /> diff --git a/src/components/Apps/AppsDevModeNavBar.tsx b/src/components/Apps/AppsDevModeNavBar.tsx index 0bfc0c9..548f1d2 100644 --- a/src/components/Apps/AppsDevModeNavBar.tsx +++ b/src/components/Apps/AppsDevModeNavBar.tsx @@ -32,17 +32,9 @@ export const AppsDevModeNavBar = () => { 'tutorial', ]); const theme = useTheme(); + const [isNewTabWindow, setIsNewTabWindow] = useState(false); const tabsRef = useRef(null); - const [anchorEl, setAnchorEl] = useState(null); - - const handleClick = (event) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; useEffect(() => { // Scroll to the last tab whenever the tabs array changes (e.g., when a new tab is added) diff --git a/src/components/Apps/AppsHomeDesktop.tsx b/src/components/Apps/AppsHomeDesktop.tsx index be7eaaf..428b0cf 100644 --- a/src/components/Apps/AppsHomeDesktop.tsx +++ b/src/components/Apps/AppsHomeDesktop.tsx @@ -22,6 +22,7 @@ export const AppsHomeDesktop = ({ myWebsite, availableQapps, myName, + myAddress, }) => { const [qortalUrl, setQortalUrl] = useState(''); const theme = useTheme(); @@ -157,7 +158,7 @@ export const AppsHomeDesktop = ({ - + { +export const AppsPrivate = ({ myName, myAddress }) => { + const [names, setNames] = useState([]); + const [name, setName] = useState(0); + const { openApp } = useHandlePrivateApps(); const [file, setFile] = useState(null); const [logo, setLogo] = useState(null); @@ -90,6 +100,8 @@ export const AppsPrivate = ({ myName }) => { name: '', }); + const mySortedNames = useSortedMyNames(names, myName); + const { getRootProps, getInputProps } = useDropzone({ accept: { 'application/zip': ['.zip'], // Only accept zip files @@ -210,6 +222,8 @@ export const AppsPrivate = ({ myName }) => { data: decryptedData, identifier: newPrivateAppValues?.identifier, service: newPrivateAppValues?.service, + uploadType: 'base64', + name, }) .then((response) => { if (!response?.error) { @@ -232,7 +246,7 @@ export const AppsPrivate = ({ myName }) => { { identifier: newPrivateAppValues?.identifier, service: newPrivateAppValues?.service, - name: myName, + name, groupId: selectedGroup, }, true @@ -262,6 +276,22 @@ export const AppsPrivate = ({ myName }) => { }; } + const getNames = useCallback(async () => { + if (!myAddress) return; + try { + const res = await fetch( + `${getBaseApiReact()}/names/address/${myAddress}?limit=0` + ); + const data = await res.json(); + setNames(data?.map((item) => item.name)); + } catch (error) { + console.error(error); + } + }, [myAddress]); + useEffect(() => { + getNames(); + }, [getNames]); + return ( <> { postProcess: 'capitalizeFirstChar', })} + + + + + + { return convert(htmlString, { @@ -76,13 +77,16 @@ export const ChatOptions = ({ return untransformedMessages?.map((item) => { if (item?.messageText) { let transformedMessage = item?.messageText; + const isHtml = isHtmlString(item?.messageText); try { - transformedMessage = generateHTML(item?.messageText, [ - StarterKit, - Underline, - Highlight, - Mention, - ]); + transformedMessage = isHtml + ? item?.messageText + : generateHTML(item?.messageText, [ + StarterKit, + Underline, + Highlight, + Mention, + ]); return { ...item, messageText: transformedMessage, diff --git a/src/components/Chat/GroupAvatar.tsx b/src/components/Chat/GroupAvatar.tsx index 12c78ec..c90d87e 100644 --- a/src/components/Chat/GroupAvatar.tsx +++ b/src/components/Chat/GroupAvatar.tsx @@ -109,6 +109,7 @@ export const GroupAvatar = ({ data: avatarBase64, identifier: `qortal_group_avatar_${groupId}`, service: 'THUMBNAIL', + uploadType: 'base64', }) .then((response) => { if (!response?.error) { diff --git a/src/components/Chat/MessageItem.tsx b/src/components/Chat/MessageItem.tsx index 4d2a653..b16b07f 100644 --- a/src/components/Chat/MessageItem.tsx +++ b/src/components/Chat/MessageItem.tsx @@ -47,7 +47,11 @@ import level8Img from '../../assets/badges/level-8.png'; import level9Img from '../../assets/badges/level-9.png'; import level10Img from '../../assets/badges/level-10.png'; import { Embed } from '../Embeds/Embed'; -import { buildImageEmbedLink, messageHasImage } from '../../utils/chat'; +import { + buildImageEmbedLink, + isHtmlString, + messageHasImage, +} from '../../utils/chat'; import { useTranslation } from 'react-i18next'; const getBadgeImg = (level) => { @@ -135,6 +139,8 @@ export const MessageItem = memo( const htmlText = useMemo(() => { if (message?.messageText) { + const isHtml = isHtmlString(message?.messageText); + if (isHtml) return message?.messageText; return generateHTML(message?.messageText, [ StarterKit, Underline, @@ -147,6 +153,8 @@ export const MessageItem = memo( const htmlReply = useMemo(() => { if (reply?.messageText) { + const isHtml = isHtmlString(reply?.messageText); + if (isHtml) return reply?.messageText; return generateHTML(reply?.messageText, [ StarterKit, Underline, @@ -628,6 +636,18 @@ export const ReplyPreview = ({ message, isEdit = false }) => { 'tutorial', ]); + const replyMessageText = useMemo(() => { + const isHtml = isHtmlString(message?.messageText); + if (isHtml) return message?.messageText; + return generateHTML(message?.messageText, [ + StarterKit, + Underline, + Highlight, + Mention, + TextStyle, + ]); + }, [message?.messageText]); + return ( { )} {message?.messageText && ( - + )} {message?.decryptedData?.type === 'notification' ? ( diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 105f258..f4091f2 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -265,11 +265,11 @@ export const getDataPublishesFunc = async (groupId, type) => { }; export async function getNameInfo(address: string) { - const response = await fetch(`${getBaseApiReact()}/names/address/` + address); + const response = await fetch(`${getBaseApiReact()}/names/primary/` + address); const nameData = await response.json(); - if (nameData?.length > 0) { - return nameData[0]?.name; + if (nameData?.name) { + return nameData?.name; } else { return ''; } @@ -523,7 +523,7 @@ export const Group = ({ }); }); } catch (error) { - console.log('error', error); + console.error(error); } }, [setMutedGroups]); @@ -2357,6 +2357,7 @@ export const Group = ({ hasUnreadDirects={directChatHasUnread} show={desktopViewMode === 'apps'} myName={userInfo?.name} + myAddress={userInfo?.address} isGroups={isOpenSideViewGroups} isDirects={isOpenSideViewDirects} hasUnreadGroups={groupChatHasUnread || groupsAnnHasUnread} diff --git a/src/components/Group/ListOfGroupPromotions.tsx b/src/components/Group/ListOfGroupPromotions.tsx index e04832d..a3a91c8 100644 --- a/src/components/Group/ListOfGroupPromotions.tsx +++ b/src/components/Group/ListOfGroupPromotions.tsx @@ -229,6 +229,7 @@ export const ListOfGroupPromotions = () => { data: data, identifier: identifier, service: 'DOCUMENT', + uploadType: 'base64', }) .then((response) => { if (!response?.error) { diff --git a/src/components/MainAvatar.tsx b/src/components/MainAvatar.tsx index 58ac4c9..fbacc18 100644 --- a/src/components/MainAvatar.tsx +++ b/src/components/MainAvatar.tsx @@ -104,6 +104,7 @@ export const MainAvatar = ({ myName, balance, setOpenSnack, setInfoSnack }) => { data: avatarBase64, identifier: 'qortal_avatar', service: 'THUMBNAIL', + uploadType: 'base64', }) .then((response) => { if (!response?.error) { diff --git a/src/components/Minting/Minting.tsx b/src/components/Minting/Minting.tsx index 4add25d..198b5e1 100644 --- a/src/components/Minting/Minting.tsx +++ b/src/components/Minting/Minting.tsx @@ -87,14 +87,14 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { const getName = async (address) => { try { const response = await fetch( - `${getBaseApiReact()}/names/address/${address}` + `${getBaseApiReact()}/names/primary/${address}` ); const nameData = await response.json(); - if (nameData?.length > 0) { + if (nameData?.name) { setNames((prev) => { return { ...prev, - [address]: nameData[0].name, + [address]: nameData?.name, }; }); } else { diff --git a/src/components/Save/Save.tsx b/src/components/Save/Save.tsx index 986ee65..3d2b7cc 100644 --- a/src/components/Save/Save.tsx +++ b/src/components/Save/Save.tsx @@ -171,6 +171,7 @@ export const Save = ({ isDesktop, disableWidth, myName }) => { data: encryptData, identifier: 'ext_saved_settings', service: 'DOCUMENT_PRIVATE', + uploadType: 'base64', }) .then((response) => { if (!response?.error) { diff --git a/src/encryption/encryption.ts b/src/encryption/encryption.ts index 04c3b65..2bf82f6 100644 --- a/src/encryption/encryption.ts +++ b/src/encryption/encryption.ts @@ -26,15 +26,24 @@ export async function getNameInfo() { const wallet = await getSaveWallet(); const address = wallet.address0; const validApi = await getBaseApi(); - const response = await fetch(validApi + '/names/address/' + address); + const response = await fetch(validApi + '/names/primary/' + address); const nameData = await response.json(); - if (nameData?.length > 0) { - return nameData[0].name; + if (nameData?.name) { + return nameData?.name; } else { return ''; } } +export async function getAllUserNames() { + const wallet = await getSaveWallet(); + const address = wallet.address0; + const validApi = await getBaseApi(); + const response = await fetch(validApi + '/names/address/' + address); + const nameData = await response.json(); + return nameData.map((item) => item.name); +} + async function getKeyPair() { const res = await getData('keyPair').catch(() => null); if (res) { @@ -134,12 +143,12 @@ export const encryptAndPublishSymmetricKeyGroupChat = async ({ if (encryptedData) { const registeredName = await getNameInfo(); const data = await publishData({ - registeredName, + data: encryptedData, file: encryptedData, - service: 'DOCUMENT_PRIVATE', identifier: `symmetric-qchat-group-${groupId}`, - uploadType: 'file', - isBase64: true, + registeredName, + service: 'DOCUMENT_PRIVATE', + uploadType: 'base64', withFee: true, }); return { @@ -202,12 +211,12 @@ export const encryptAndPublishSymmetricKeyGroupChatForAdmins = async ({ if (encryptedData) { const registeredName = await getNameInfo(); const data = await publishData({ - registeredName, + data: encryptedData, file: encryptedData, - service: 'DOCUMENT_PRIVATE', identifier: `admins-symmetric-qchat-group-${groupId}`, - uploadType: 'file', - isBase64: true, + registeredName, + service: 'DOCUMENT_PRIVATE', + uploadType: 'base64', withFee: true, }); return { @@ -240,12 +249,12 @@ export const publishGroupEncryptedResource = async ({ }) ); const data = await publishData({ - registeredName, + data: encryptedData, file: encryptedData, - service: 'DOCUMENT', identifier, - uploadType: 'file', - isBase64: true, + registeredName, + service: 'DOCUMENT', + uploadType: 'base64', withFee: true, }); return data; @@ -262,21 +271,22 @@ export const publishGroupEncryptedResource = async ({ }; export const publishOnQDN = async ({ - data, - identifier, - service, - title, - description, category, + data, + description, + identifier, + name, + service, tag1, tag2, tag3, tag4, tag5, - uploadType = 'file', + title, + uploadType = 'base64', }) => { if (data && service) { - const registeredName = await getNameInfo(); + const registeredName = name || (await getNameInfo()); if (!registeredName) throw new Error( i18n.t('core:message.generic.name_publish', { @@ -286,11 +296,11 @@ export const publishOnQDN = async ({ const res = await publishData({ registeredName, + data, file: data, service, identifier, uploadType, - isBase64: true, withFee: true, title, description, diff --git a/src/hooks/useQortalMessageListener.tsx b/src/hooks/useQortalMessageListener.tsx index 37ec1c3..d2a54c4 100644 --- a/src/hooks/useQortalMessageListener.tsx +++ b/src/hooks/useQortalMessageListener.tsx @@ -256,6 +256,7 @@ export const listOfAllQortalRequests = [ 'UPDATE_GROUP', 'UPDATE_NAME', 'VOTE_ON_POLL', + 'GET_PRIMARY_NAME', ]; export const UIQortalRequests = [ @@ -319,6 +320,7 @@ export const UIQortalRequests = [ 'UPDATE_GROUP', 'UPDATE_NAME', 'VOTE_ON_POLL', + 'GET_PRIMARY_NAME', ]; async function retrieveFileFromIndexedDB(fileId) { @@ -615,13 +617,22 @@ export const useQortalMessageListener = ( ); } else if (event?.data?.action === 'SAVE_FILE') { try { - const res = await saveFile(event.data, null, true, { + await saveFile(event.data, null, true, { openSnackGlobal, setOpenSnackGlobal, infoSnackCustom, setInfoSnackCustom, }); - } catch (error) {} + event.ports[0].postMessage({ + result: true, + error: null, + }); + } catch (error) { + event.ports[0].postMessage({ + result: null, + error: error?.message || 'Failed to save file', + }); + } } else if ( event?.data?.action === 'PUBLISH_MULTIPLE_QDN_RESOURCES' || event?.data?.action === 'PUBLISH_QDN_RESOURCE' || diff --git a/src/hooks/useSortedMyNames.tsx b/src/hooks/useSortedMyNames.tsx new file mode 100644 index 0000000..6fb8917 --- /dev/null +++ b/src/hooks/useSortedMyNames.tsx @@ -0,0 +1,11 @@ +import { useMemo } from 'react'; + +export function useSortedMyNames(names, myName) { + return useMemo(() => { + return [...names].sort((a, b) => { + if (a === myName) return -1; + if (b === myName) return 1; + return 0; + }); + }, [names, myName]); +} diff --git a/src/qdn/publish/publish.ts b/src/qdn/publish/publish.ts index 46a6759..782ecdd 100644 --- a/src/qdn/publish/publish.ts +++ b/src/qdn/publish/publish.ts @@ -34,6 +34,44 @@ async function reusablePost(endpoint, _body) { return data; } +async function reusablePostStream(endpoint, _body) { + const url = await createEndpoint(endpoint); + + const headers = {}; + + const response = await fetch(url, { + method: 'POST', + headers, + body: _body, + }); + + return response; // return the actual response so calling code can use response.ok +} + +async function uploadChunkWithRetry(endpoint, formData, index, maxRetries = 3) { + let attempt = 0; + while (attempt < maxRetries) { + try { + const response = await reusablePostStream(endpoint, formData); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText); + } + return; // Success + } catch (err) { + attempt++; + console.warn( + `Chunk ${index} failed (attempt ${attempt}): ${err.message}` + ); + if (attempt >= maxRetries) { + throw new Error(`Chunk ${index} failed after ${maxRetries} attempts`); + } + // Wait 10 seconds before next retry + await new Promise((res) => setTimeout(res, 10_000)); + } + } +} + async function getKeyPair() { const res = await getData('keyPair').catch(() => null); if (res) { @@ -44,23 +82,22 @@ async function getKeyPair() { } export const publishData = async ({ - registeredName, - file, - service, - identifier, - uploadType, - isBase64, - filename, - withFee, - title, - description, category, + data, + description, + feeAmount, + filename, + identifier, + registeredName, + service, tag1, tag2, tag3, tag4, tag5, - feeAmount, + title, + uploadType, + withFee, }: any) => { const validateName = async (receiverName: string) => { return await reusableGet(`/names/${receiverName}`); @@ -186,7 +223,8 @@ export const publishData = async ({ } } - let transactionBytes = await uploadData(registeredName, file, fee); + let transactionBytes = await uploadData(registeredName, data, fee); + if (!transactionBytes || transactionBytes.error) { throw new Error(transactionBytes?.message || 'Error when uploading'); } else if (transactionBytes.includes('Error 500 Internal Server Error')) { @@ -206,79 +244,119 @@ export const publishData = async ({ return signAndProcessRes; }; - const uploadData = async (registeredName: string, file: any, fee: number) => { + const uploadData = async (registeredName: string, data: any, fee: number) => { let postBody = ''; let urlSuffix = ''; - if (file != null) { - // If we're sending zipped data, make sure to use the /zip version of the POST /arbitrary/* API - if (uploadType === 'zip') { - urlSuffix = '/zip'; - } - - // If we're sending file data, use the /base64 version of the POST /arbitrary/* API - else if (uploadType === 'file') { + if (data != null) { + if (uploadType === 'base64') { urlSuffix = '/base64'; } - // Base64 encode the file to work around compatibility issues between javascript and java byte arrays - if (isBase64) { - postBody = file; - } - - if (!isBase64) { - let fileBuffer = new Uint8Array(await file.arrayBuffer()); - postBody = Buffer.from(fileBuffer).toString('base64'); + if (uploadType === 'base64') { + postBody = data; } + } else { + throw new Error('No data provided'); } - let uploadDataUrl = `/arbitrary/${service}/${registeredName}${urlSuffix}`; + let uploadDataUrl = `/arbitrary/${service}/${registeredName}`; + let paramQueries = ''; if (identifier?.trim().length > 0) { - uploadDataUrl = `/arbitrary/${service}/${registeredName}/${identifier}${urlSuffix}`; + uploadDataUrl = `/arbitrary/${service}/${registeredName}/${identifier}`; } - uploadDataUrl = uploadDataUrl + `?fee=${fee}`; + paramQueries = paramQueries + `?fee=${fee}`; if (filename != null && filename != 'undefined') { - uploadDataUrl = - uploadDataUrl + '&filename=' + encodeURIComponent(filename); + paramQueries = paramQueries + '&filename=' + encodeURIComponent(filename); } if (title != null && title != 'undefined') { - uploadDataUrl = uploadDataUrl + '&title=' + encodeURIComponent(title); + paramQueries = paramQueries + '&title=' + encodeURIComponent(title); } if (description != null && description != 'undefined') { - uploadDataUrl = - uploadDataUrl + '&description=' + encodeURIComponent(description); + paramQueries = + paramQueries + '&description=' + encodeURIComponent(description); } if (category != null && category != 'undefined') { - uploadDataUrl = - uploadDataUrl + '&category=' + encodeURIComponent(category); + paramQueries = paramQueries + '&category=' + encodeURIComponent(category); } if (tag1 != null && tag1 != 'undefined') { - uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag1); + paramQueries = paramQueries + '&tags=' + encodeURIComponent(tag1); } if (tag2 != null && tag2 != 'undefined') { - uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag2); + paramQueries = paramQueries + '&tags=' + encodeURIComponent(tag2); } if (tag3 != null && tag3 != 'undefined') { - uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag3); + paramQueries = paramQueries + '&tags=' + encodeURIComponent(tag3); } if (tag4 != null && tag4 != 'undefined') { - uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag4); + paramQueries = paramQueries + '&tags=' + encodeURIComponent(tag4); } if (tag5 != null && tag5 != 'undefined') { - uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag5); + paramQueries = paramQueries + '&tags=' + encodeURIComponent(tag5); + } + if (uploadType === 'zip') { + paramQueries = paramQueries + '&isZip=' + true; } - return await reusablePost(uploadDataUrl, postBody); + if (uploadType === 'base64') { + if (urlSuffix) { + uploadDataUrl = uploadDataUrl + urlSuffix; + } + uploadDataUrl = uploadDataUrl + paramQueries; + return await reusablePost(uploadDataUrl, postBody); + } + + const file = data; + const urlCheck = `/arbitrary/check/tmp?totalSize=${file.size}`; + + const checkEndpoint = await createEndpoint(urlCheck); + const checkRes = await fetch(checkEndpoint); + if (!checkRes.ok) { + throw new Error('Not enough space on your hard drive'); + } + + const chunkUrl = uploadDataUrl + `/chunk`; + const chunkSize = 5 * 1024 * 1024; // 5MB + + const totalChunks = Math.ceil(file.size / chunkSize); + + for (let index = 0; index < totalChunks; index++) { + const start = index * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const chunk = file.slice(start, end); + + const formData = new FormData(); + formData.append('chunk', chunk, file.name); // Optional: include filename + formData.append('index', index); + + await uploadChunkWithRetry(chunkUrl, formData, index); + } + const finalizeUrl = uploadDataUrl + `/finalize` + paramQueries; + + const finalizeEndpoint = await createEndpoint(finalizeUrl); + + const response = await fetch(finalizeEndpoint, { + method: 'POST', + headers: {}, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Finalize failed: ${errorText}`); + } + + const result = await response.text(); // Base58-encoded unsigned transaction + return result; }; try { diff --git a/src/qortal/get.ts b/src/qortal/get.ts index 102a9bf..7bd9bee 100644 --- a/src/qortal/get.ts +++ b/src/qortal/get.ts @@ -38,7 +38,11 @@ import { getPublicKey, transferAsset, } from '../background/background.ts'; -import { getNameInfo, uint8ArrayToObject } from '../encryption/encryption.ts'; +import { + getAllUserNames, + getNameInfo, + uint8ArrayToObject, +} from '../encryption/encryption.ts'; import { showSaveFilePicker } from '../hooks/useQortalMessageListener.tsx'; import { getPublishesFromAdminsAdminSpace } from '../components/Chat/AdminSpaceInner.tsx'; import { extractComponents } from '../components/Chat/MessageDisplay.tsx'; @@ -1315,7 +1319,7 @@ export const publishQDNResource = async ( if (appFee && appFee > 0 && appFeeRecipient) { hasAppFee = true; } - const registeredName = await getNameInfo(); + const registeredName = data?.name || (await getNameInfo()); const name = registeredName; if (!name) { throw new Error( @@ -1331,7 +1335,7 @@ export const publishQDNResource = async ( const title = data.title; const description = data.description; const category = data.category; - + const file = data?.file || data?.blob; const tags = data?.tags || []; const result = {}; @@ -1346,9 +1350,7 @@ export const publishQDNResource = async ( if (data.identifier == null) { identifier = 'default'; } - if (data?.file || data?.blob) { - data64 = await fileToBase64(data?.file || data?.blob); - } + if ( data.encrypt && (!data.publicKeys || @@ -1367,6 +1369,9 @@ export const publishQDNResource = async ( const parsedData = resKeyPair; const privateKey = parsedData.privateKey; const userPublicKey = parsedData.publicKey; + if (data?.file || data?.blob) { + data64 = await fileToBase64(data?.file || data?.blob); + } const encryptDataResponse = encryptDataGroup({ data64, publicKeys: data.publicKeys, @@ -1410,6 +1415,7 @@ export const publishQDNResource = async ( }), text2: `service: ${service}`, text3: `identifier: ${identifier || null}`, + text4: `name: ${registeredName}`, fee: fee.fee, ...handleDynamicValues, }, @@ -1420,11 +1426,10 @@ export const publishQDNResource = async ( try { const resPublish = await publishData({ registeredName: encodeURIComponent(name), - file: data64, + data: data64 ? data64 : file, service: service, identifier: encodeURIComponent(identifier), - uploadType: 'file', - isBase64: true, + uploadType: data64 ? 'base64' : 'file', filename: filename, title, description, @@ -1558,6 +1563,7 @@ export const publishMultipleQDNResources = async ( const fee = await getFee('ARBITRARY'); const registeredName = await getNameInfo(); + const name = registeredName; if (!name) { @@ -1568,6 +1574,14 @@ export const publishMultipleQDNResources = async ( ); } + const userNames = await getAllUserNames(); + data.resources?.forEach((item) => { + if (item?.name && !userNames?.includes(item.name)) + throw new Error( + `The name ${item.name}, does not belong to the publisher.` + ); + }); + const appFee = data?.appFee ? +data.appFee : undefined; const appFeeRecipient = data?.appFeeRecipient; let hasAppFee = false; @@ -1638,7 +1652,7 @@ export const publishMultipleQDNResources = async (
Service: ${ resource.service }
-
Name: ${name}
+
Name: ${resource?.name || name}
Identifier: ${ resource.identifier }
@@ -1695,6 +1709,7 @@ export const publishMultipleQDNResources = async ( reason: errorMsg, identifier: resource.identifier, service: resource.service, + name: resource?.name || name, }); continue; } @@ -1709,12 +1724,13 @@ export const publishMultipleQDNResources = async ( reason: errorMsg, identifier: resource.identifier, service: resource.service, + name: resource?.name || name, }); continue; } const service = resource.service; let identifier = resource.identifier; - let data64 = resource?.data64 || resource?.base64; + let rawData = resource?.data64 || resource?.base64; const filename = resource.filename; const title = resource.title; const description = resource.description; @@ -1741,26 +1757,31 @@ export const publishMultipleQDNResources = async ( reason: errorMsg, identifier: resource.identifier, service: resource.service, + name: resource?.name || name, }); continue; } if (resource.file) { - data64 = await fileToBase64(resource.file); + rawData = resource.file; } + if (resourceEncrypt) { try { + if (resource?.file) { + rawData = await fileToBase64(resource.file); + } const resKeyPair = await getKeyPair(); const parsedData = resKeyPair; const privateKey = parsedData.privateKey; const userPublicKey = parsedData.publicKey; const encryptDataResponse = encryptDataGroup({ - data64, + data64: rawData, publicKeys: data.publicKeys, privateKey, userPublicKey, }); if (encryptDataResponse) { - data64 = encryptDataResponse; + rawData = encryptDataResponse; } } catch (error) { const errorMsg = @@ -1772,32 +1793,36 @@ export const publishMultipleQDNResources = async ( reason: errorMsg, identifier: resource.identifier, service: resource.service, + name: resource?.name || name, }); continue; } } try { + const dataType = + resource?.base64 || resource?.data64 || resourceEncrypt + ? 'base64' + : 'file'; await retryTransaction( publishData, [ { - registeredName: encodeURIComponent(name), - file: data64, - service: service, - identifier: encodeURIComponent(identifier), - uploadType: 'file', - isBase64: true, - filename: filename, - title, - description, + apiVersion: 2, category, + data: rawData, + description, + filename: filename, + identifier: encodeURIComponent(identifier), + registeredName: encodeURIComponent(resource?.name || name), + service: service, tag1, tag2, tag3, tag4, tag5, - apiVersion: 2, + title, + uploadType: dataType, withFee: true, }, ], @@ -1818,6 +1843,7 @@ export const publishMultipleQDNResources = async ( reason: errorMsg, identifier: resource.identifier, service: resource.service, + name: resource?.name || name, }); } } catch (error) { @@ -1829,6 +1855,7 @@ export const publishMultipleQDNResources = async ( }), identifier: resource.identifier, service: resource.service, + name: resource?.name || name, }); } } @@ -2324,6 +2351,44 @@ export const joinGroup = async (data, isFromExtension) => { export const saveFile = async (data, sender, isFromExtension, snackMethods) => { try { + if (!data?.filename) throw new Error('Missing filename'); + if (data?.location) { + const requiredFieldsLocation = ['service', 'name']; + const missingFieldsLocation: string[] = []; + requiredFieldsLocation.forEach((field) => { + if (!data?.location[field]) { + missingFieldsLocation.push(field); + } + }); + if (missingFieldsLocation.length > 0) { + const missingFieldsString = missingFieldsLocation.join(', '); + const errorMsg = `Missing fields: ${missingFieldsString}`; + throw new Error(errorMsg); + } + const resPermission = await getUserPermission( + { + text1: 'Would you like to download:', + highlightedText: `${data?.filename}`, + }, + isFromExtension + ); + const { accepted } = resPermission; + if (!accepted) throw new Error('User declined to save file'); + const a = document.createElement('a'); + let locationUrl = `/arbitrary/${data.location.service}/${data.location.name}`; + if (data.location.identifier) { + locationUrl = locationUrl + `/${data.location.identifier}`; + } + const endpoint = await createEndpoint( + locationUrl + `?attachment=true&attachmentFilename=${data?.filename}` + ); + a.href = endpoint; + a.download = data.filename; + document.body.appendChild(a); + a.click(); + a.remove(); + return true; + } const requiredFields = ['filename', 'blob']; const missingFields: string[] = []; requiredFields.forEach((field) => { @@ -2341,6 +2406,8 @@ export const saveFile = async (data, sender, isFromExtension, snackMethods) => { } const filename = data.filename; const blob = data.blob; + + const mimeType = blob.type || data.mimeType; const resPermission = await getUserPermission( { text1: i18n.t('question:download_file', { @@ -2351,6 +2418,17 @@ export const saveFile = async (data, sender, isFromExtension, snackMethods) => { isFromExtension ); const { accepted } = resPermission; + if (!accepted) throw new Error('User declined to save file'); // TODO translate + showSaveFilePicker( + { + filename, + mimeType, + blob, + }, + snackMethods + ); + + return true; if (accepted) { const mimeType = blob.type || data.mimeType; @@ -5396,11 +5474,10 @@ export const updateNameRequest = async (data, isFromExtension) => { const fee = await getFee('UPDATE_NAME'); const resPermission = await getUserPermission( { - text1: i18n.t('question:permission.register_name', { - postProcess: 'capitalizeFirstChar', - }), - highlightedText: data.newName, - text2: data?.description, + text1: `Do you give this application permission to update this name?`, // TODO translate + text2: `previous name: ${oldName}`, + text3: `new name: ${newName}`, + text4: data?.description, fee: fee.fee, }, isFromExtension @@ -6012,7 +6089,7 @@ export const createGroupRequest = async (data, isFromExtension) => { ]; const missingFields: string[] = []; requiredFields.forEach((field) => { - if (data[field] !== undefined && data[field] !== null) { + if (data[field] === undefined || data[field] === null) { missingFields.push(field); } }); @@ -6076,7 +6153,7 @@ export const updateGroupRequest = async (data, isFromExtension) => { ]; const missingFields: string[] = []; requiredFields.forEach((field) => { - if (data[field] !== undefined && data[field] !== null) { + if (data[field] === undefined || data[field] === null) { missingFields.push(field); } }); @@ -6250,7 +6327,7 @@ export const sellNameRequest = async (data, isFromExtension) => { const requiredFields = ['salePrice', 'nameForSale']; const missingFields: string[] = []; requiredFields.forEach((field) => { - if (data[field] !== undefined && data[field] !== null) { + if (data[field] === undefined || data[field] === null) { missingFields.push(field); } }); @@ -6320,7 +6397,7 @@ export const cancelSellNameRequest = async (data, isFromExtension) => { const requiredFields = ['nameForSale']; const missingFields: string[] = []; requiredFields.forEach((field) => { - if (data[field] !== undefined && data[field] !== null) { + if (data[field] === undefined || data[field] === null) { missingFields.push(field); } }); @@ -6377,7 +6454,7 @@ export const buyNameRequest = async (data, isFromExtension) => { const requiredFields = ['nameForSale']; const missingFields: string[] = []; requiredFields.forEach((field) => { - if (data[field] !== undefined && data[field] !== null) { + if (data[field] === undefined || data[field] === null) { missingFields.push(field); } }); @@ -6908,12 +6985,11 @@ export const multiPaymentWithPrivateData = async (data, isFromExtension) => { [ { registeredName: encodeURIComponent(name), - file: encryptDataResponse, + data: encryptDataResponse, service: transaction.service, identifier: encodeURIComponent(transaction.identifier), - uploadType: 'file', + uploadType: 'base64', description: transaction?.description, - isBase64: true, apiVersion: 2, withFee: true, }, diff --git a/src/qortal/qortal-requests.ts b/src/qortal/qortal-requests.ts index 6a164b6..f67b922 100644 --- a/src/qortal/qortal-requests.ts +++ b/src/qortal/qortal-requests.ts @@ -1,4 +1,8 @@ -import { gateways, getApiKeyFromStorage } from '../background/background.ts'; +import { + gateways, + getApiKeyFromStorage, + getNameInfoForOthers, +} from '../background/background.ts'; import { listOfAllQortalRequests } from '../hooks/useQortalMessageListener.tsx'; import { addForeignServer, @@ -1932,6 +1936,33 @@ function setupMessageListenerQortalRequest() { break; } + case 'GET_PRIMARY_NAME': { + try { + const res = await getNameInfoForOthers(request.payload?.address); + const resData = res ? res : null; + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + payload: resData, + 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/utils/chat.ts b/src/utils/chat.ts index c7099c0..fed287d 100644 --- a/src/utils/chat.ts +++ b/src/utils/chat.ts @@ -20,3 +20,7 @@ export const messageHasImage = (message) => { message.images[0]?.service ); }; + +export function isHtmlString(value) { + return typeof value === 'string' && /<[^>]+>/.test(value.trim()); +} diff --git a/src/utils/time.ts b/src/utils/time.ts index e434cfb..d8d1ef4 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -44,14 +44,14 @@ export function sortArrayByTimestampAndGroupName(array) { // Both have timestamp, sort by timestamp descending return b.timestamp - a.timestamp; } else if (a.timestamp) { - // Only `a` has timestamp, it comes first return -1; } else if (b.timestamp) { - // Only `b` has timestamp, it comes first return 1; } else { - // Neither has timestamp, sort alphabetically by groupName - return a.groupName.localeCompare(b.groupName); + // Neither has timestamp, sort alphabetically by groupName (with fallback) + const nameA = a.groupName || ''; + const nameB = b.groupName || ''; + return nameA.localeCompare(nameB); } }); }