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 d85455b..38507d6 100644 --- a/src/components/Chat/ChatGroup.tsx +++ b/src/components/Chat/ChatGroup.tsx @@ -15,15 +15,22 @@ 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, Typography, IconButton, + Tooltip } 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 { isExtMsg, getFee } from '../../background' import { throttle } from 'lodash' import AppViewerContainer from '../Apps/AppViewerContainer' import CloseIcon from "@mui/icons-material/Close"; +import ImageIcon from '@mui/icons-material/Image'; +import { messageHasImage } from '../../utils/chat'; + + + +const uidImages = new ShortUniqueId({ length: 12 }); const uid = new ShortUniqueId({ length: 5 }); @@ -47,7 +54,9 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, const [isOpenQManager, setIsOpenQManager] = useState(null) const [onEditMessage, setOnEditMessage] = useState(null) const [messageSize, setMessageSize] = useState(0) - const {isUserBlocked} = useContext(MyContext) + const {isUserBlocked, show} = useContext(MyContext) + const [chatImagesToSave, setChatImagesToSave] = useState([]); + const [isDeleteImage, setIsDeleteImage] = useState(false); const { queueChats, addToQueue, processWithNewMessages } = useMessageQueue(); const [, forceUpdate] = useReducer((x) => x + 1, 0); @@ -610,11 +619,98 @@ const sendMessage = async ()=> { const publicData = isPrivate ? {} : { isEdited : chatReference ? true : false, } + const imagesToPublish = []; + const deleteImage = + onEditMessage && isDeleteImage && messageHasImage(onEditMessage); + if (deleteImage) { + const fee = await getFee('ARBITRARY'); + + await show({ + publishFee: fee.fee + ' QORT', + message: 'Would you like to delete your previous chat image?', + }); + + await new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "publishOnQDN", + payload: { + data: 'RA==', + identifier: onEditMessage?.images[0]?.identifier, + service: onEditMessage?.images[0]?.service, + }, + }, + (response) => { + + if (!response?.error) { + res(response); + return + } + rej(response.error); + } + ); + }); + } + 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 new Promise((res, rej) => { + chrome?.runtime?.sendMessage( + { + action: "PUBLISH_MULTIPLE_QDN_RESOURCES", + type: "qortalRequest", + payload: { + resources: imagesToPublish, + }, + }, + (response) => { + if (response.error) { + rej(response?.message); + return; + } else { + res(response); + + } + } + ); + }); + if (res !== true) throw new Error('Unable to publish images'); + } + + 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, ...publicData } const objectMessage = { @@ -650,6 +746,8 @@ const sendMessage = async ()=> { clearEditorContent() setReplyMessage(null) setOnEditMessage(null) + setIsDeleteImage(false); + setChatImagesToSave([]); } // send chat message } catch (error) { @@ -712,6 +810,8 @@ useEffect(() => { } setReplyMessage(message) setOnEditMessage(null) + setIsDeleteImage(false); + setChatImagesToSave([]); editorRef?.current?.chain().focus() }, []) @@ -781,6 +881,24 @@ useEffect(() => { } }, [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 (
{ width: 'calc(100% - 100px)', justifyContent: 'flex-end' }}> + + {!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 && ( { setReplyMessage(null) setOnEditMessage(null) + setIsDeleteImage(false); + setChatImagesToSave([]); }} > @@ -857,7 +1088,8 @@ useEffect(() => { onClick={() => { setReplyMessage(null) setOnEditMessage(null) - + setIsDeleteImage(false); + setChatImagesToSave([]); clearEditorContent() }} @@ -868,7 +1100,7 @@ useEffect(() => { )} - + {messageSize > 750 && ( { switch(level?.toString()){ @@ -321,6 +323,9 @@ const onSeenFunc = useCallback(()=> { ) : ( )} + {message?.images && messageHasImage(message) && ( + + )} { - if (editor) { + if (editor && !isChat) { editor.view.dom.addEventListener("paste", handlePaste); return () => { editor.view.dom.removeEventListener("paste", handlePaste); }; } - }, [editor]); + }, [editor, isChat]); return (
@@ -340,7 +341,8 @@ export default ({ overrideMobile, customEditorHeight, membersWithNames, - enableMentions + enableMentions, + insertImage, }) => { const [isDisabledEditorEnter, setIsDisabledEditorEnter] = useRecoilState(isDisabledEditorEnterAtom) const extensionsFiltered = isChat @@ -370,6 +372,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]); @@ -520,6 +551,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/qortalRequests/get.ts b/src/qortalRequests/get.ts index cdc7adb..f456a5d 100644 --- a/src/qortalRequests/get.ts +++ b/src/qortalRequests/get.ts @@ -1477,7 +1477,7 @@ if (accepted || skip) { }; const messageObject = fullMessageObject ? fullMessageObject : { messageText: tiptapJson, - images: [""], + images: [], repliedTo: "", version: 3, }; diff --git a/src/utils/chat.ts b/src/utils/chat.ts new file mode 100644 index 0000000..c7099c0 --- /dev/null +++ b/src/utils/chat.ts @@ -0,0 +1,22 @@ +export function buildImageEmbedLink(image?: { + name?: string; + identifier?: string; + service?: string; + timestamp?: number; +}): string | null { + if (!image?.name || !image.identifier || !image.service) return null; + + const base = `qortal://use-embed/IMAGE?name=${image.name}&identifier=${image.identifier}&service=${image.service}&mimeType=image%2Fpng×tamp=${image?.timestamp || ''}`; + + const isEncrypted = image.identifier.startsWith('grp-q-manager_0'); + return isEncrypted ? `${base}&encryptionType=group` : base; +} + +export const messageHasImage = (message) => { + return ( + Array.isArray(message?.images) && + message.images[0]?.identifier && + message.images[0]?.name && + message.images[0]?.service + ); +};