diff --git a/src/common/useModal.tsx b/src/common/useModal.tsx index b9ab713..e1d0272 100644 --- a/src/common/useModal.tsx +++ b/src/common/useModal.tsx @@ -48,7 +48,7 @@ export const useModal = () => { const onCancel = () => { const { reject } = promiseConfig.current; hide(); - reject(); + reject('Declined'); setMessage({ publishFee: "", message: "" diff --git a/src/components/Chat/ChatGroup.tsx b/src/components/Chat/ChatGroup.tsx index 3248587..2de131e 100644 --- a/src/components/Chat/ChatGroup.tsx +++ b/src/components/Chat/ChatGroup.tsx @@ -15,12 +15,12 @@ import { CustomizedSnackbars } from '../Snackbar/Snackbar' import { PUBLIC_NOTIFICATION_CODE_FIRST_SECRET_KEY } from '../../constants/codes' import { useMessageQueue } from '../../MessageQueueContext' import { executeEvent, subscribeToEvent, unsubscribeFromEvent } from '../../utils/events' -import { Box, ButtonBase, Divider, Typography } from '@mui/material' +import { Box, ButtonBase, Divider, IconButton, Tooltip, Typography } from '@mui/material' import ShortUniqueId from "short-unique-id"; import { ReplyPreview } from './MessageItem' import { ExitIcon } from '../../assets/Icons/ExitIcon' import { RESOURCE_TYPE_NUMBER_GROUP_CHAT_REACTIONS } from '../../constants/resourceTypes' -import { isExtMsg } from '../../background' +import { getFee, isExtMsg } from '../../background' import MentionList from './MentionList' import { ChatOptions } from './ChatOptions' import { isFocusedParentGroupAtom } from '../../atoms/global' @@ -28,6 +28,10 @@ import { useRecoilState } from 'recoil' import AppViewerContainer from '../Apps/AppViewerContainer' import CloseIcon from "@mui/icons-material/Close"; import { throttle } from 'lodash' +import ImageIcon from '@mui/icons-material/Image'; +import { messageHasImage } from '../../utils/chat' + +const uidImages = new ShortUniqueId({ length: 12 }); const uid = new ShortUniqueId({ length: 5 }); @@ -55,8 +59,9 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, const editorRef = useRef(null); const { queueChats, addToQueue, processWithNewMessages } = useMessageQueue(); const handleUpdateRef = useRef(null); - const {isUserBlocked} = useContext(MyContext) - + const {isUserBlocked, show} = useContext(MyContext) + const [chatImagesToSave, setChatImagesToSave] = useState([]); + const [isDeleteImage, setIsDeleteImage] = useState(false); const lastReadTimestamp = useRef(null) @@ -624,6 +629,8 @@ if(isFocusedParent === false){ setReplyMessage(null) setOnEditMessage(null) clearEditorContent() + setIsDeleteImage(false); + setChatImagesToSave([]); } }, [isFocusedParent]) const clearEditorContent = () => { @@ -644,88 +651,193 @@ const clearEditorContent = () => { -const sendMessage = async ()=> { +const sendMessage = async () => { try { - if(messageSize > 4000) return - if(isPrivate === null) throw new Error('Unable to determine if group is private') - if(isSending) return - if(+balance < 4) throw new Error('You need at least 4 QORT to send a message') - pauseAllQueues() + if (messageSize > 4000) return; // TODO magic number + if (isPrivate === null) + throw new Error( + "Onable to determine if group is private" + ); + if (isSending) return; + if (+balance < 4) + // TODO magic number + throw new Error( + "You need at least 4 QORT to send a message" + ); + pauseAllQueues(); if (editorRef.current) { - const htmlContent = editorRef.current.getHTML(); - - if(!htmlContent?.trim() || htmlContent?.trim() === '

') return - + let htmlContent = editorRef.current.getHTML(); + const deleteImage = + onEditMessage && isDeleteImage && messageHasImage(onEditMessage); - setIsSending(true) - const message = isPrivate === false ? editorRef.current.getJSON() : htmlContent - const secretKeyObject = await getSecretKey(false, true) + const hasImage = + chatImagesToSave?.length > 0 || onEditMessage?.images?.length > 0; + if ( + (!htmlContent?.trim() || htmlContent?.trim() === '

') && + !hasImage && + !deleteImage + ) + return; + if (htmlContent?.trim() === '

') { + htmlContent = null; + } + setIsSending(true); + const message = + isPrivate === false + ? !htmlContent + ? '

' + : editorRef.current.getJSON() + : htmlContent; + const secretKeyObject = await getSecretKey(false, true); - let repliedTo = replyMessage?.signature + let repliedTo = replyMessage?.signature; - if (replyMessage?.chatReference) { - repliedTo = replyMessage?.chatReference - } - let chatReference = onEditMessage?.signature + if (replyMessage?.chatReference) { + repliedTo = replyMessage?.chatReference; + } - const publicData = isPrivate ? {} : { - isEdited : chatReference ? true : false, - } - const otherData = { - repliedTo, - ...(onEditMessage?.decryptedData || {}), - type: chatReference ? 'edit' : '', - specialId: uid.rnd(), - ...publicData - } - const objectMessage = { - ...(otherData || {}), - [isPrivate ? 'message' : 'messageText']: message, - version: 3 - } - const message64: any = await objectToBase64(objectMessage) - - const encryptSingle = isPrivate === false ? JSON.stringify(objectMessage) : await encryptChatMessage(message64, secretKeyObject) - // const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle}) - - const sendMessageFunc = async () => { - return await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle, chatReference}) - }; + const chatReference = onEditMessage?.signature; - // Add the function to the queue - const messageObj = { - message: { - text: htmlContent, - timestamp: Date.now(), - senderName: myName, - sender: myAddress, - ...(otherData || {}) - }, - chatReference - } - addToQueue(sendMessageFunc, messageObj, 'chat', - selectedGroup ); - setTimeout(() => { - executeEvent("sent-new-message-group", {}) - }, 150); - clearEditorContent() - setReplyMessage(null) - setOnEditMessage(null) + const publicData = isPrivate + ? {} + : { + isEdited: chatReference ? true : false, + }; + + interface ImageToPublish { + service: string; + identifier: string; + name: string; + base64: string; + } + + const imagesToPublish: ImageToPublish[] = []; + + if (deleteImage) { + const fee = await getFee('ARBITRARY'); + await show({ + publishFee: fee.fee + ' QORT', + message: "Would you like to delete your previous chat image?", + }); + + // TODO magic string + await window.sendMessage('publishOnQDN', { + data: 'RA==', + identifier: onEditMessage?.images[0]?.identifier, + service: onEditMessage?.images[0]?.service, + uploadType: 'base64', + }); + } + + if (chatImagesToSave?.length > 0) { + const imageToSave = chatImagesToSave[0]; + + const base64ToSave = isPrivate + ? await encryptChatMessage(imageToSave, secretKeyObject) + : imageToSave; + + // 1 represents public group, 0 is private + const identifier = `grp-q-manager_${isPrivate ? 0 : 1}_group_${selectedGroup}_${uidImages.rnd()}`; + imagesToPublish.push({ + service: 'IMAGE', + identifier, + name: myName, + base64: base64ToSave, + }); + + const res = await window.sendMessage( + 'PUBLISH_MULTIPLE_QDN_RESOURCES', + { + resources: imagesToPublish, + }, + 240000, + true + ); + if (res !== true) + throw new Error( + "Unable to publish image" + ); + } + + const images = + imagesToPublish?.length > 0 + ? imagesToPublish.map((item) => { + return { + name: item.name, + identifier: item.identifier, + service: item.service, + timestamp: Date.now(), + }; + }) + : chatReference + ? isDeleteImage + ? [] + : onEditMessage?.images || [] + : []; + + const otherData = { + repliedTo, + ...(onEditMessage?.decryptedData || {}), + type: chatReference ? 'edit' : '', + specialId: uid.rnd(), + images: images, + ...publicData, + }; + const objectMessage = { + ...(otherData || {}), + [isPrivate ? 'message' : 'messageText']: message, + version: 3, + }; + const message64: any = await objectToBase64(objectMessage); + + const encryptSingle = + isPrivate === false + ? JSON.stringify(objectMessage) + : await encryptChatMessage(message64, secretKeyObject); + + const sendMessageFunc = async () => { + return await sendChatGroup({ + groupId: selectedGroup, + messageText: encryptSingle, + chatReference, + }); + }; + + // Add the function to the queue + const messageObj = { + message: { + text: htmlContent, + timestamp: Date.now(), + senderName: myName, + sender: myAddress, + ...(otherData || {}), + }, + chatReference, + }; + addToQueue(sendMessageFunc, messageObj, 'chat', selectedGroup); + setTimeout(() => { + executeEvent('sent-new-message-group', {}); + }, 150); + clearEditorContent(); + setReplyMessage(null); + setOnEditMessage(null); + setIsDeleteImage(false); + setChatImagesToSave([]); } // send chat message } catch (error) { - const errorMsg = error?.message || error + const errorMsg = error?.message || error; setInfoSnack({ - type: "error", + type: 'error', message: errorMsg, }); setOpenSnack(true); - console.error(error) + console.error(error); } finally { - setIsSending(false) - resumeAllQueues() + setIsSending(false); + resumeAllQueues(); } -} +}; useEffect(() => { if (hide) { @@ -742,7 +854,8 @@ const sendMessage = async ()=> { setReplyMessage(message) setOnEditMessage(null) setIsFocusedParent(true); - + setIsDeleteImage(false); + setChatImagesToSave([]); setTimeout(() => { editorRef?.current?.chain().focus() @@ -755,7 +868,7 @@ const sendMessage = async ()=> { setReplyMessage(null) setIsFocusedParent(true); setTimeout(() => { - editorRef.current.chain().focus().setContent(message?.messageText || message?.text).run(); + editorRef?.current?.chain().focus().setContent(message?.messageText || message?.text || '

').run(); }, 250); }, []) @@ -824,6 +937,24 @@ const sendMessage = async ()=> { resumeAllQueues() } }, [isPrivate]) + + const insertImage = useCallback( + (img) => { + if ( + chatImagesToSave?.length > 0 || + (messageHasImage(onEditMessage) && !isDeleteImage) + ) { + setInfoSnack({ + type: 'error', + message: 'This message already has an image', + }); + setOpenSnack(true); + return; + } + setChatImagesToSave((prev) => [...prev, img]); + }, + [chatImagesToSave, onEditMessage?.images, isDeleteImage] + ); return (
{ overflow: !isMobile && "auto", flexShrink: 0 }}> + + {!isDeleteImage && + onEditMessage && + messageHasImage(onEditMessage) && + onEditMessage?.images?.map((_, index) => ( +
+ + + setIsDeleteImage(true)} + size="small" + sx={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + backgroundColor: (theme) => + theme.palette.background.paper, + color: (theme) => theme.palette.text.primary, + borderRadius: '50%', + opacity: 0, + transition: 'opacity 0.2s', + boxShadow: (theme) => theme.shadows[2], + '&:hover': { + backgroundColor: (theme) => + theme.palette.background.default, + opacity: 1, + }, + pointerEvents: 'auto', + }} + > + + + +
+ ))} + {chatImagesToSave.map((imgBase64, index) => ( +
+ + + + setChatImagesToSave((prev) => + prev.filter((_, i) => i !== index) + ) + } + size="small" + sx={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + backgroundColor: (theme) => + theme.palette.background.paper, + color: (theme) => theme.palette.text.primary, + borderRadius: '50%', + opacity: 0, + transition: 'opacity 0.2s', + boxShadow: (theme) => theme.shadows[2], + '&:hover': { + backgroundColor: (theme) => + theme.palette.background.default, + opacity: 1, + }, + pointerEvents: 'auto', + }} + > + + + +
+ ))} +
{replyMessage && ( { }}> - + diff --git a/src/components/Chat/ChatList.tsx b/src/components/Chat/ChatList.tsx index 82dc915..cd1aa51 100644 --- a/src/components/Chat/ChatList.tsx +++ b/src/components/Chat/ChatList.tsx @@ -261,20 +261,17 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR if (chatReferences?.[message.signature]) { reactions = chatReferences[message.signature]?.reactions || null; - if (chatReferences[message.signature]?.edit?.message && message?.text) { - message.text = chatReferences[message.signature]?.edit?.message; - message.isEdit = true - message.editTimestamp = chatReferences[message.signature]?.edit?.timestamp - } - if (chatReferences[message.signature]?.edit?.messageText && message?.messageText) { - message.messageText = chatReferences[message.signature]?.edit?.messageText; - message.isEdit = true - message.editTimestamp = chatReferences[message.signature]?.edit?.timestamp - } - if (chatReferences[message.signature]?.edit?.images) { + if (chatReferences[message.signature]?.edit) { + message.text = + chatReferences[message.signature]?.edit?.message; + message.messageText = + chatReferences[message.signature]?.edit?.messageText; message.images = chatReferences[message.signature]?.edit?.images; + message.isEdit = true; + message.editTimestamp = + chatReferences[message.signature]?.edit?.timestamp; } } diff --git a/src/components/Chat/MessageItem.tsx b/src/components/Chat/MessageItem.tsx index c134d1d..18d7d0e 100644 --- a/src/components/Chat/MessageItem.tsx +++ b/src/components/Chat/MessageItem.tsx @@ -35,6 +35,7 @@ import level9Img from "../../assets/badges/level-9.png" import level10Img from "../../assets/badges/level-10.png" import { Embed } from "../Embeds/Embed"; import { buildImageEmbedLink, isHtmlString, messageHasImage } from "../../utils/chat"; +import CommentsDisabledIcon from '@mui/icons-material/CommentsDisabled'; const getBadgeImg = (level)=> { switch(level?.toString()){ @@ -142,6 +143,13 @@ const onSeenFunc = useCallback(()=> { onSeen(message.id); }, [message?.id]) +const hasNoMessage = +(!message.decryptedData?.data?.message || + message.decryptedData?.data?.message === '

') && +(message?.images || [])?.length === 0 && +(!message?.messageText || message?.messageText === '

') && +(!message?.text || message?.text === '

'); + return ( {message?.divide && ( @@ -335,7 +343,7 @@ const onSeenFunc = useCallback(()=> {
)} - {message?.messageText && ( + {htmlText && !hasNoMessage && ( { )} {message?.decryptedData?.type === "notification" ? ( - ) : ( + ) : hasNoMessage ? null : ( )} + {hasNoMessage && ( + + + + No Message + + + )} {message?.images && messageHasImage(message) && ( )} @@ -523,6 +549,7 @@ const onSeenFunc = useCallback(()=> { export const ReplyPreview = ({message, isEdit})=> { const replyMessageText = useMemo(() => { + if (!message?.messageText) return null; const isHtml = isHtmlString(message?.messageText); if (isHtml) return message?.messageText; return generateHTML(message?.messageText, [ @@ -568,7 +595,7 @@ export const ReplyPreview = ({message, isEdit})=> { }}>Replied to {message?.senderName || message?.senderAddress} )} - {message?.messageText && ( + {replyMessageText && ( diff --git a/src/components/Chat/TipTap.tsx b/src/components/Chat/TipTap.tsx index 38b2126..e644a64 100644 --- a/src/components/Chat/TipTap.tsx +++ b/src/components/Chat/TipTap.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { EditorProvider, useCurrentEditor, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { Color } from "@tiptap/extension-color"; @@ -34,6 +34,7 @@ import ListItemButton from '@mui/material/ListItemButton'; import ListItemText from '@mui/material/ListItemText'; import { ReactRenderer } from '@tiptap/react' import MentionList from './MentionList.jsx' +import { fileToBase64 } from "../../utils/fileReading/index.js"; function textMatcher(doc, from) { const textBeforeCursor = doc.textBetween(0, from, ' ', ' '); @@ -110,13 +111,13 @@ const MenuBar = ({ setEditorRef, isChat }) => { }; useEffect(() => { - if (editor) { + if (editor && !isChat) { editor.view.dom.addEventListener("paste", handlePaste); return () => { editor.view.dom.removeEventListener("paste", handlePaste); }; } - }, [editor]); + }, [editor, isChat]); return (
@@ -299,7 +300,8 @@ export default ({ customEditorHeight, membersWithNames, enableMentions, - isReply + isReply, + insertImage, }) => { const extensionsFiltered = isChat @@ -329,7 +331,35 @@ export default ({ }, [membersWithNames]) + const handleImageUpload = useCallback(async (file) => { + try { + if (!file.type.includes('image')) return; + let compressedFile = file; + if (file.type !== 'image/gif') { + await new Promise((resolve) => { + new Compressor(file, { + quality: 0.6, + maxWidth: 1200, + mimeType: 'image/webp', + success(result) { + compressedFile = result; + resolve(); + }, + error(err) { + console.error('Image compression error:', err); + }, + }); + }); + } + if (compressedFile) { + const toBase64 = await fileToBase64(compressedFile); + insertImage(toBase64); + } + } catch (error) { + console.error(error); + } + }, [insertImage]); const usersRef = useRef([]); @@ -470,6 +500,25 @@ export default ({ } return false; }, + handlePaste(view, event) { + if(!handleImageUpload) return + if (!isChat) return; + const items = event.clipboardData?.items; + if (!items) return false; + + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + event.preventDefault(); // Block the default paste + handleImageUpload(file); // Custom handler + return true; // Let ProseMirror know we handled it + } + } + } + + return false; // fallback to default behavior otherwise + }, }} />
diff --git a/src/components/Embeds/ImageEmbed.tsx b/src/components/Embeds/ImageEmbed.tsx index 8e02e63..e401737 100644 --- a/src/components/Embeds/ImageEmbed.tsx +++ b/src/components/Embeds/ImageEmbed.tsx @@ -52,6 +52,8 @@ export const ImageCard = ({ backgroundColor: "#1F2023", height: height, transition: "height 0.6s ease-in-out", + display: 'flex', + flexDirection: 'column', }} > - - + + @@ -203,6 +215,7 @@ export const ImageCard = ({ display: "flex", justifyContent: "center", cursor: "pointer", + height: '100%', }} onClick={handleOpenFullscreen} > diff --git a/src/components/Home/NewUsersCTA.tsx b/src/components/Home/NewUsersCTA.tsx index 486c4da..12a9bfb 100644 --- a/src/components/Home/NewUsersCTA.tsx +++ b/src/components/Home/NewUsersCTA.tsx @@ -54,35 +54,19 @@ export const NewUsersCTA = ({ balance }) => { textDecoration: "underline", }} onClick={() => { - if (chrome && chrome.tabs) { - chrome.tabs.create({ url: "https://link.qortal.dev/telegram-invite" }, (tab) => { - if (chrome.runtime.lastError) { - console.error("Error opening tab:", chrome.runtime.lastError); - } else { - console.log("Tab opened successfully:", tab); - } - }); - } - + window.open("https://link.qortal.dev/support", '_system') }} > - Telegram + Nextcloud { - if (chrome && chrome.tabs) { - chrome.tabs.create({ url: "https://link.qortal.dev/discord-invite" }, (tab) => { - if (chrome.runtime.lastError) { - console.error("Error opening tab:", chrome.runtime.lastError); - } else { - console.log("Tab opened successfully:", tab); - } - }); - } + window.open("https://link.qortal.dev/discord-invite", '_system') }} + > Discord diff --git a/src/constants/constants.ts b/src/constants/constants.ts index 5b5e427..72b7632 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -173,3 +173,6 @@ const STATIC_BCRYPT_SALT = `$${BCRYPT_VERSION}$${BCRYPT_ROUNDS}$IxVE941tXVUD4cW0 const KDF_THREADS = 16 export { TX_TYPES, ERROR_CODES, QORT_DECIMALS, PROXY_URL, STATIC_SALT, ADDRESS_VERSION, KDF_THREADS, STATIC_BCRYPT_SALT, CHAT_REFERENCE_FEATURE_TRIGGER_TIMESTAMP, DYNAMIC_FEE_TIMESTAMP } + +export const MAX_SIZE_PUBLIC_NODE = 500 * 1024 * 1024; // 500mb +export const MAX_SIZE_PUBLISH = 2000 * 1024 * 1024; // 2GB \ No newline at end of file diff --git a/src/qortalRequests/get.ts b/src/qortalRequests/get.ts index 220c292..55c4ce9 100644 --- a/src/qortalRequests/get.ts +++ b/src/qortalRequests/get.ts @@ -42,7 +42,7 @@ import { } from "../background"; import { getNameInfo, uint8ArrayToObject,getAllUserNames } from "../backgroundFunctions/encryption"; import { saveFileInChunksFromUrl, showSaveFilePicker } from "../components/Apps/useQortalMessageListener"; -import { QORT_DECIMALS } from "../constants/constants"; +import { MAX_SIZE_PUBLIC_NODE, MAX_SIZE_PUBLISH, QORT_DECIMALS } from "../constants/constants"; import Base58 from "../deps/Base58"; import { base64ToUint8Array, @@ -955,6 +955,22 @@ export const publishQDNResource = async ( const tags = data?.tags || []; const result = {}; + + if (file && file.size > MAX_SIZE_PUBLISH) { + throw new Error( + "Maximum file size allowed is 2 GB per file" + ); + } + + if (file && file.size > MAX_SIZE_PUBLIC_NODE) { + const isPublicNode = await isRunningGateway(); + if (isPublicNode) { + throw new Error( + "Maximum file size allowed on the public node is 500 MB. Please use your local node for larger files." + ); + } + } + // Fill tags dynamically while maintaining backward compatibility for (let i = 0; i < 5; i++) { result[`tag${i + 1}`] = tags[i] || data[`tag${i + 1}`] || undefined; @@ -1125,6 +1141,31 @@ export const publishMultipleQDNResources = async ( throw new Error('No resources to publish'); } + const isPublicNode = await isRunningGateway(); + if (isPublicNode) { + const hasOversizedFilePublicNode = resources.some((resource) => { + const file = resource?.file; + return file instanceof File && file.size > MAX_SIZE_PUBLIC_NODE; + }); + + if (hasOversizedFilePublicNode) { + throw new Error( + "Maximum file size allowed on the public node is 500 MB. Please use your local node for larger files." + ); + } + } + + const hasOversizedFile = resources.some((resource) => { + const file = resource?.file; + return file instanceof File && file.size > MAX_SIZE_PUBLISH; + }); + + if (hasOversizedFile) { + throw new Error( + "Maximum file size allowed is 2 GB per file" + ); + } + const encrypt = data?.encrypt; for (const resource of resources) {