diff --git a/package-lock.json b/package-lock.json index 5cb6ac6..fe43916 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "buffer": "6.0.3", "compressorjs": "^1.2.1", "dompurify": "^3.1.6", + "emoji-picker-react": "^4.12.0", "file-saver": "^2.0.5", "jssha": "3.3.1", "lodash": "^4.17.21", @@ -4128,6 +4129,20 @@ "integrity": "sha512-w+9yAVHoHhysCa+gln7AzbO9CdjFcL/wN/5dd+XW/Msl2d/4+WisEaCF1nty0xbAKaxdaJfgLB2296U7zZB7BA==", "dev": true }, + "node_modules/emoji-picker-react": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.0.tgz", + "integrity": "sha512-q2c8UcZH0eRIMj41bj0k1akTjk69tsu+E7EzkW7giN66iltF6H9LQvQvw6ugscsxdC+1lmt3WZpQkkY65J95tg==", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4725,6 +4740,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==" + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", diff --git a/package.json b/package.json index 915aa55..4d0fa0c 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "buffer": "6.0.3", "compressorjs": "^1.2.1", "dompurify": "^3.1.6", + "emoji-picker-react": "^4.12.0", "file-saver": "^2.0.5", "jssha": "3.3.1", "lodash": "^4.17.21", diff --git a/src/background.ts b/src/background.ts index 431fc98..bdb1862 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1541,6 +1541,7 @@ async function sendChatGroup({ chatReference, messageText, }) { + let _reference = new Uint8Array(64); self.crypto.getRandomValues(_reference); @@ -1557,18 +1558,23 @@ async function sendChatGroup({ // const hasEnoughBalance = +balance < 4 ? false : true; const difficulty = 8; - const tx = await createTransaction(181, keyPair, { + const txBody = { timestamp: Date.now(), groupID: Number(groupId), hasReceipient: 0, - hasChatReference: typeMessage === "edit" ? 1 : 0, - // chatReference: chatReference, + hasChatReference: chatReference ? 1 : 0, message: messageText, lastReference: reference, proofOfWorkNonce: 0, isEncrypted: 0, // Set default to not encrypted for groups isText: 1, - }); + } + + if(chatReference){ + txBody['chatReference'] = chatReference + } + + const tx = await createTransaction(181, keyPair, txBody); // if (!hasEnoughBalance) { // throw new Error("Must have at least 4 QORT to send a chat message"); diff --git a/src/components/Chat/ChatGroup.tsx b/src/components/Chat/ChatGroup.tsx index b2e477f..1022fa7 100644 --- a/src/components/Chat/ChatGroup.tsx +++ b/src/components/Chat/ChatGroup.tsx @@ -29,6 +29,7 @@ const uid = new ShortUniqueId({ length: 5 }); export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, myAddress, handleNewEncryptionNotification, hide, handleSecretKeyCreationInProgress, triedToFetchSecretKey, myName, balance}) => { const [messages, setMessages] = useState([]) + const [chatReferences, setChatReferences] = useState({}) const [isSending, setIsSending] = useState(false) const [isLoading, setIsLoading] = useState(false) const [isMoved, setIsMoved] = useState(false); @@ -56,7 +57,14 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, const tempMessages = useMemo(()=> { if(!selectedGroup) return [] if(queueChats[selectedGroup]){ - return queueChats[selectedGroup] + return queueChats[selectedGroup]?.filter((item)=> !item?.chatReference) + } + return [] + }, [selectedGroup, queueChats]) + const tempChatReferences = useMemo(()=> { + if(!selectedGroup) return [] + if(queueChats[selectedGroup]){ + return queueChats[selectedGroup]?.filter((item)=> !!item?.chatReference) } return [] }, [selectedGroup, queueChats]) @@ -91,6 +99,7 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, }) } + const decryptMessages = (encryptedMessages: any[])=> { try { @@ -113,7 +122,7 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, res(response) if(hasInitialized.current){ - const formatted = response.map((item: any)=> { + const formatted = response.filter((rawItem)=> !rawItem?.chatReference).map((item: any)=> { return { ...item, id: item.signature, @@ -123,8 +132,66 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, } } ) setMessages((prev)=> [...prev, ...formatted]) + + + setChatReferences((prev) => { + let organizedChatReferences = { ...prev }; + + response + .filter((rawItem) => rawItem && rawItem.chatReference && rawItem.decryptedData?.type === 'reaction') + .forEach((item) => { + try { + const content = item.decryptedData?.content; + const sender = item.sender; + const newTimestamp = item.timestamp; + const contentState = item.decryptedData?.contentState; + + if (!content || typeof content !== 'string' || !sender || typeof sender !== 'string' || !newTimestamp) { + console.warn("Invalid content, sender, or timestamp in reaction data", item); + return; + } + + // Initialize chat reference and reactions if not present + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + reactions: organizedChatReferences[item.chatReference]?.reactions || {} + }; + + organizedChatReferences[item.chatReference].reactions[content] = + organizedChatReferences[item.chatReference].reactions[content] || []; + + const existingReactionIndex = organizedChatReferences[item.chatReference].reactions[content] + .findIndex(reaction => reaction.sender === sender); + + // Handle contentState: if false, remove the reaction + if (contentState === false) { + if (existingReactionIndex !== -1) { + organizedChatReferences[item.chatReference].reactions[content].splice(existingReactionIndex, 1); + } + } else { + // Add or update reaction + if (existingReactionIndex !== -1) { + const existingReaction = organizedChatReferences[item.chatReference].reactions[content][existingReactionIndex]; + const existingTimestamp = existingReaction.timestamp; + + if (newTimestamp > existingTimestamp) { + organizedChatReferences[item.chatReference].reactions[content][existingReactionIndex] = item; + } + } else { + organizedChatReferences[item.chatReference].reactions[content].push(item); + } + } + } catch (error) { + console.error("Error processing reaction item:", error, item); + } + }); + + return organizedChatReferences; + }); + + } else { - const formatted = response.map((item: any)=> { + const formatted = response.filter((rawItem)=> !rawItem?.chatReference).map((item: any)=> { return { ...item, id: item.signature, @@ -135,7 +202,64 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, } ) setMessages(formatted) hasInitialized.current = true - + setChatReferences((prev) => { + let organizedChatReferences = { ...prev }; + + response + .filter((rawItem) => rawItem && rawItem.chatReference && rawItem.decryptedData?.type === 'reaction') + .forEach((item) => { + try { + const content = item.decryptedData?.content; + const sender = item.sender; + const newTimestamp = item.timestamp; + const contentState = item.decryptedData?.contentState; + + if (!content || typeof content !== 'string' || !sender || typeof sender !== 'string' || !newTimestamp) { + console.warn("Invalid content, sender, or timestamp in reaction data", item); + return; + } + + // Initialize chat reference and reactions if not present + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + reactions: organizedChatReferences[item.chatReference]?.reactions || {} + }; + + organizedChatReferences[item.chatReference].reactions[content] = + organizedChatReferences[item.chatReference].reactions[content] || []; + + const existingReactionIndex = organizedChatReferences[item.chatReference].reactions[content] + .findIndex(reaction => reaction.sender === sender); + + // Handle contentState: if false, remove the reaction + if (contentState === false) { + if (existingReactionIndex !== -1) { + organizedChatReferences[item.chatReference].reactions[content].splice(existingReactionIndex, 1); + } + } else { + // Add or update reaction + if (existingReactionIndex !== -1) { + const existingReaction = organizedChatReferences[item.chatReference].reactions[content][existingReactionIndex]; + const existingTimestamp = existingReaction.timestamp; + + if (newTimestamp > existingTimestamp) { + organizedChatReferences[item.chatReference].reactions[content][existingReactionIndex] = item; + } + } else { + organizedChatReferences[item.chatReference].reactions[content].push(item); + } + } + } catch (error) { + console.error("Error processing reaction item:", error, item); + } + }); + + return organizedChatReferences; + }); + + + + } } rej(response.error) @@ -386,6 +510,73 @@ const clearEditorContent = () => { const onReply = useCallback((message)=> { setReplyMessage(message) }, []) + + const handleReaction = useCallback(async (reaction, chatMessage, reactionState = true)=> { + try { + + if(isSending) return + if(+balance < 4) throw new Error('You need at least 4 QORT to send a message') + pauseAllQueues() + + + setIsSending(true) + const message = '' + const secretKeyObject = await getSecretKey(false, true) + + + const otherData = { + specialId: uid.rnd(), + type: 'reaction', + content: reaction, + contentState: reactionState + } + const objectMessage = { + message, + ...(otherData || {}) + } + const message64: any = await objectToBase64(objectMessage) + + const encryptSingle = await encryptChatMessage(message64, secretKeyObject) + // const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle}) + + const sendMessageFunc = async () => { + await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle, chatReference: chatMessage.signature}) + }; + + // Add the function to the queue + const messageObj = { + message: { + text: message, + timestamp: Date.now(), + senderName: myName, + sender: myAddress, + ...(otherData || {}) + }, + chatReference: chatMessage.signature + } + addToQueue(sendMessageFunc, messageObj, 'chat-reaction', + selectedGroup ); + // setTimeout(() => { + // executeEvent("sent-new-message-group", {}) + // }, 150); + // clearEditorContent() + // setReplyMessage(null) + + // send chat message + } catch (error) { + const errorMsg = error?.message || error + setInfoSnack({ + type: "error", + message: errorMsg, + }); + setOpenSnack(true); + console.error(error) + } finally { + setIsSending(false) + resumeAllQueues() + } + }, []) + return (
{ left: hide && '-100000px', }}> - +
{ +export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onReply, handleReaction, chatReferences, tempChatReferences }) => { const virtuosoRef = useRef(); const [messages, setMessages] = useState(initialMessages); const [showScrollButton, setShowScrollButton] = useState(false); @@ -70,10 +70,7 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR }, []); const scrollToBottom = (initialMsgs) => { - console.log('initialMsgs', { - initialMsgs, - messages - }) + const index = initialMsgs ? initialMsgs.length - 1 : messages.length - 1 if (virtuosoRef.current) { virtuosoRef.current.scrollToIndex({ index}); @@ -109,6 +106,7 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR let replyIndex = messages.findIndex((msg)=> msg?.signature === message?.repliedTo) let reply + let reactions = null if(message?.repliedTo && replyIndex !== -1){ reply = messages[replyIndex] } @@ -125,6 +123,16 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR } } + if(chatReferences && chatReferences[message?.signature]){ + if(chatReferences[message.signature]?.reactions){ + reactions = chatReferences[message.signature]?.reactions + } + } + let isUpdating = false + if(tempChatReferences && tempChatReferences?.find((item)=> item?.chatReference === message?.signature)){ + isUpdating = true + } + return (
); diff --git a/src/components/Chat/MessageItem.tsx b/src/components/Chat/MessageItem.tsx index eabe671..20e6f77 100644 --- a/src/components/Chat/MessageItem.tsx +++ b/src/components/Chat/MessageItem.tsx @@ -14,6 +14,7 @@ import { executeEvent } from "../../utils/events"; import { WrapperUserAction } from "../WrapperUserAction"; import ReplyIcon from "@mui/icons-material/Reply"; import { Spacer } from "../../common/Spacer"; +import { ReactionPicker } from "../ReactionPicker"; export const MessageItem = ({ message, @@ -25,7 +26,10 @@ export const MessageItem = ({ isShowingAsReply, reply, replyIndex, - scrollToItem + scrollToItem, + handleReaction, + reactions, + isUpdating }) => { const { ref, inView } = useInView({ threshold: 0.7, // Fully visible @@ -48,7 +52,7 @@ export const MessageItem = ({ width: "95%", display: "flex", gap: "7px", - opacity: isTemp ? 0.5 : 1, + opacity: (isTemp || isUpdating) ? 0.5 : 1, }} id={message?.signature} > @@ -110,6 +114,11 @@ export const MessageItem = ({ {message?.senderName || message?.sender} + {!isShowingAsReply && ( { @@ -119,6 +128,18 @@ export const MessageItem = ({ )} + {!isShowingAsReply && handleReaction && ( + { + + if(reactions && reactions[val] && reactions[val]?.find((item)=> item?.sender === myAddress)){ + handleReaction(val, message, false) + } else { + handleReaction(val, message, true) + } + + }} /> + )} + {reply && ( <> @@ -187,11 +208,49 @@ export const MessageItem = ({ - {isTemp ? ( + + {reactions && Object.keys(reactions).map((reaction)=> { + const numberOfReactions = reactions[reaction]?.length + // const myReaction = reactions + if(numberOfReactions === 0) return null + return ( + { + if(reactions[reaction] && reactions[reaction]?.find((item)=> item?.sender === myAddress)){ + handleReaction(reaction, message, false) + } else { + handleReaction(reaction, message, true) + } + }}> +
{reaction}
+
+ ) + })} +
+ + {isUpdating ? ( + + Updating... + + ) : isTemp ? ( { + const [showPicker, setShowPicker] = useState(false); // Manage picker visibility + const pickerRef = useRef(null); // Reference to the picker + + const handleReaction = (emojiObject) => { + onReaction(emojiObject.emoji); // Handle the selected emoji reaction + setShowPicker(false); // Close picker after selection + }; + const handlePicker = (emojiObject) => { + + onReaction(emojiObject.emoji); // Handle the selected emoji reaction + setShowPicker(false); // Close picker after selection + }; + + // Close picker if clicked outside + useEffect(() => { + const handleClickOutside = (event) => { + if (pickerRef.current && !pickerRef.current.contains(event.target)) { + setShowPicker(false); // Close picker + } + }; + + // Add event listener when picker is shown + if (showPicker) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + // Clean up the event listener on unmount + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showPicker]); + + return ( +
+ {/* Emoji CTA */} + setShowPicker(!showPicker)}> + 😃 + + + {/* Emoji Picker with dark theme */} + {showPicker && ( +
+ +
+ )} +
+ ); +};