added group chat reactions

This commit is contained in:
PhilReact 2024-09-26 23:26:46 +03:00
parent 6f2787a0d4
commit 0810381455
8 changed files with 384 additions and 18 deletions

20
package-lock.json generated
View File

@ -32,6 +32,7 @@
"buffer": "6.0.3", "buffer": "6.0.3",
"compressorjs": "^1.2.1", "compressorjs": "^1.2.1",
"dompurify": "^3.1.6", "dompurify": "^3.1.6",
"emoji-picker-react": "^4.12.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jssha": "3.3.1", "jssha": "3.3.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@ -4128,6 +4129,20 @@
"integrity": "sha512-w+9yAVHoHhysCa+gln7AzbO9CdjFcL/wN/5dd+XW/Msl2d/4+WisEaCF1nty0xbAKaxdaJfgLB2296U7zZB7BA==", "integrity": "sha512-w+9yAVHoHhysCa+gln7AzbO9CdjFcL/wN/5dd+XW/Msl2d/4+WisEaCF1nty0xbAKaxdaJfgLB2296U7zZB7BA==",
"dev": true "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": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@ -4725,6 +4740,11 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/flat-cache": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",

View File

@ -36,6 +36,7 @@
"buffer": "6.0.3", "buffer": "6.0.3",
"compressorjs": "^1.2.1", "compressorjs": "^1.2.1",
"dompurify": "^3.1.6", "dompurify": "^3.1.6",
"emoji-picker-react": "^4.12.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jssha": "3.3.1", "jssha": "3.3.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View File

@ -1541,6 +1541,7 @@ async function sendChatGroup({
chatReference, chatReference,
messageText, messageText,
}) { }) {
let _reference = new Uint8Array(64); let _reference = new Uint8Array(64);
self.crypto.getRandomValues(_reference); self.crypto.getRandomValues(_reference);
@ -1557,18 +1558,23 @@ async function sendChatGroup({
// const hasEnoughBalance = +balance < 4 ? false : true; // const hasEnoughBalance = +balance < 4 ? false : true;
const difficulty = 8; const difficulty = 8;
const tx = await createTransaction(181, keyPair, { const txBody = {
timestamp: Date.now(), timestamp: Date.now(),
groupID: Number(groupId), groupID: Number(groupId),
hasReceipient: 0, hasReceipient: 0,
hasChatReference: typeMessage === "edit" ? 1 : 0, hasChatReference: chatReference ? 1 : 0,
// chatReference: chatReference,
message: messageText, message: messageText,
lastReference: reference, lastReference: reference,
proofOfWorkNonce: 0, proofOfWorkNonce: 0,
isEncrypted: 0, // Set default to not encrypted for groups isEncrypted: 0, // Set default to not encrypted for groups
isText: 1, isText: 1,
}); }
if(chatReference){
txBody['chatReference'] = chatReference
}
const tx = await createTransaction(181, keyPair, txBody);
// if (!hasEnoughBalance) { // if (!hasEnoughBalance) {
// throw new Error("Must have at least 4 QORT to send a chat message"); // throw new Error("Must have at least 4 QORT to send a chat message");

View File

@ -29,6 +29,7 @@ const uid = new ShortUniqueId({ length: 5 });
export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, myAddress, handleNewEncryptionNotification, hide, handleSecretKeyCreationInProgress, triedToFetchSecretKey, myName, balance}) => { export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, myAddress, handleNewEncryptionNotification, hide, handleSecretKeyCreationInProgress, triedToFetchSecretKey, myName, balance}) => {
const [messages, setMessages] = useState([]) const [messages, setMessages] = useState([])
const [chatReferences, setChatReferences] = useState({})
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isMoved, setIsMoved] = useState(false); const [isMoved, setIsMoved] = useState(false);
@ -56,7 +57,14 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
const tempMessages = useMemo(()=> { const tempMessages = useMemo(()=> {
if(!selectedGroup) return [] if(!selectedGroup) return []
if(queueChats[selectedGroup]){ 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 [] return []
}, [selectedGroup, queueChats]) }, [selectedGroup, queueChats])
@ -91,6 +99,7 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
}) })
} }
const decryptMessages = (encryptedMessages: any[])=> { const decryptMessages = (encryptedMessages: any[])=> {
try { try {
@ -113,7 +122,7 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
res(response) res(response)
if(hasInitialized.current){ if(hasInitialized.current){
const formatted = response.map((item: any)=> { const formatted = response.filter((rawItem)=> !rawItem?.chatReference).map((item: any)=> {
return { return {
...item, ...item,
id: item.signature, id: item.signature,
@ -123,8 +132,66 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
} }
} ) } )
setMessages((prev)=> [...prev, ...formatted]) 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 { } else {
const formatted = response.map((item: any)=> { const formatted = response.filter((rawItem)=> !rawItem?.chatReference).map((item: any)=> {
return { return {
...item, ...item,
id: item.signature, id: item.signature,
@ -135,7 +202,64 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
} ) } )
setMessages(formatted) setMessages(formatted)
hasInitialized.current = true 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) rej(response.error)
@ -386,6 +510,73 @@ const clearEditorContent = () => {
const onReply = useCallback((message)=> { const onReply = useCallback((message)=> {
setReplyMessage(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 ( return (
<div style={{ <div style={{
@ -398,7 +589,7 @@ const clearEditorContent = () => {
left: hide && '-100000px', left: hide && '-100000px',
}}> }}>
<ChatList onReply={onReply} chatId={selectedGroup} initialMessages={messages} myAddress={myAddress} tempMessages={tempMessages}/> <ChatList onReply={onReply} chatId={selectedGroup} initialMessages={messages} myAddress={myAddress} tempMessages={tempMessages} handleReaction={handleReaction} chatReferences={chatReferences} tempChatReferences={tempChatReferences}/>
<div style={{ <div style={{

View File

@ -3,7 +3,7 @@ import { Virtuoso } from 'react-virtuoso';
import { MessageItem } from './MessageItem'; import { MessageItem } from './MessageItem';
import { subscribeToEvent, unsubscribeFromEvent } from '../../utils/events'; import { subscribeToEvent, unsubscribeFromEvent } from '../../utils/events';
export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onReply }) => { export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onReply, handleReaction, chatReferences, tempChatReferences }) => {
const virtuosoRef = useRef(); const virtuosoRef = useRef();
const [messages, setMessages] = useState(initialMessages); const [messages, setMessages] = useState(initialMessages);
const [showScrollButton, setShowScrollButton] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false);
@ -70,10 +70,7 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR
}, []); }, []);
const scrollToBottom = (initialMsgs) => { const scrollToBottom = (initialMsgs) => {
console.log('initialMsgs', {
initialMsgs,
messages
})
const index = initialMsgs ? initialMsgs.length - 1 : messages.length - 1 const index = initialMsgs ? initialMsgs.length - 1 : messages.length - 1
if (virtuosoRef.current) { if (virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({ index}); 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 replyIndex = messages.findIndex((msg)=> msg?.signature === message?.repliedTo)
let reply let reply
let reactions = null
if(message?.repliedTo && replyIndex !== -1){ if(message?.repliedTo && replyIndex !== -1){
reply = messages[replyIndex] 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 ( return (
<div style={{ padding: '10px 0', display: 'flex', justifyContent: 'center', width: '100%', minHeight: '50px' , overscrollBehavior: "none"}}> <div style={{ padding: '10px 0', display: 'flex', justifyContent: 'center', width: '100%', minHeight: '50px' , overscrollBehavior: "none"}}>
<MessageItem <MessageItem
@ -137,6 +145,9 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR
reply={reply} reply={reply}
replyIndex={replyIndex} replyIndex={replyIndex}
scrollToItem={scrollToItem} scrollToItem={scrollToItem}
handleReaction={handleReaction}
reactions={reactions}
isUpdating={isUpdating}
/> />
</div> </div>
); );

View File

@ -14,6 +14,7 @@ import { executeEvent } from "../../utils/events";
import { WrapperUserAction } from "../WrapperUserAction"; import { WrapperUserAction } from "../WrapperUserAction";
import ReplyIcon from "@mui/icons-material/Reply"; import ReplyIcon from "@mui/icons-material/Reply";
import { Spacer } from "../../common/Spacer"; import { Spacer } from "../../common/Spacer";
import { ReactionPicker } from "../ReactionPicker";
export const MessageItem = ({ export const MessageItem = ({
message, message,
@ -25,7 +26,10 @@ export const MessageItem = ({
isShowingAsReply, isShowingAsReply,
reply, reply,
replyIndex, replyIndex,
scrollToItem scrollToItem,
handleReaction,
reactions,
isUpdating
}) => { }) => {
const { ref, inView } = useInView({ const { ref, inView } = useInView({
threshold: 0.7, // Fully visible threshold: 0.7, // Fully visible
@ -48,7 +52,7 @@ export const MessageItem = ({
width: "95%", width: "95%",
display: "flex", display: "flex",
gap: "7px", gap: "7px",
opacity: isTemp ? 0.5 : 1, opacity: (isTemp || isUpdating) ? 0.5 : 1,
}} }}
id={message?.signature} id={message?.signature}
> >
@ -110,6 +114,11 @@ export const MessageItem = ({
{message?.senderName || message?.sender} {message?.senderName || message?.sender}
</Typography> </Typography>
</WrapperUserAction> </WrapperUserAction>
<Box sx={{
display: 'flex',
gap: '10px',
alignItems: 'center'
}}>
{!isShowingAsReply && ( {!isShowingAsReply && (
<ButtonBase <ButtonBase
onClick={() => { onClick={() => {
@ -119,6 +128,18 @@ export const MessageItem = ({
<ReplyIcon /> <ReplyIcon />
</ButtonBase> </ButtonBase>
)} )}
{!isShowingAsReply && handleReaction && (
<ReactionPicker onReaction={(val)=> {
if(reactions && reactions[val] && reactions[val]?.find((item)=> item?.sender === myAddress)){
handleReaction(val, message, false)
} else {
handleReaction(val, message, true)
}
}} />
)}
</Box>
</Box> </Box>
{reply && ( {reply && (
<> <>
@ -187,11 +208,49 @@ export const MessageItem = ({
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
justifyContent: "flex-end", justifyContent: "space-between",
width: "100%", width: "100%",
}} }}
> >
{isTemp ? ( <Box sx={{
display: 'flex',
alignItems: 'center',
gap: '5px'
}}>
{reactions && Object.keys(reactions).map((reaction)=> {
const numberOfReactions = reactions[reaction]?.length
// const myReaction = reactions
if(numberOfReactions === 0) return null
return (
<ButtonBase sx={{
height: '30px',
minWidth: '45px',
background: 'var(--bg-2)',
borderRadius: '7px'
}} onClick={()=> {
if(reactions[reaction] && reactions[reaction]?.find((item)=> item?.sender === myAddress)){
handleReaction(reaction, message, false)
} else {
handleReaction(reaction, message, true)
}
}}>
<div>{reaction}</div>
</ButtonBase>
)
})}
</Box>
{isUpdating ? (
<Typography
sx={{
fontSize: "14px",
color: "gray",
fontFamily: "Inter",
}}
>
Updating...
</Typography>
) : isTemp ? (
<Typography <Typography
sx={{ sx={{
fontSize: "14px", fontSize: "14px",

View File

@ -0,0 +1,14 @@
.reaction-container {
position: relative; /* Parent must be positioned relatively */
}
.emoji-picker {
position: absolute; /* Picker positioned absolutely relative to the parent */
right: 0;
z-index: 1000; /* Ensure picker appears above other content */
}
.message-container {
overflow: visible; /* Ensure the message container doesn't cut off the picker */
}

View File

@ -0,0 +1,64 @@
import React, { useState, useRef, useEffect } from 'react';
import Picker, { Theme } from 'emoji-picker-react';
import './ReactionPicker.css'; // CSS for proper positioning
import { ButtonBase } from '@mui/material';
export const ReactionPicker = ({ onReaction }) => {
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 (
<div className="reaction-container">
{/* Emoji CTA */}
<ButtonBase sx={{
fontSize: '22px'
}} onClick={() => setShowPicker(!showPicker)}>
😃
</ButtonBase>
{/* Emoji Picker with dark theme */}
{showPicker && (
<div className="emoji-picker" ref={pickerRef}>
<Picker
reactionsDefaultOpen={true}
onReactionClick={handleReaction}
onEmojiClick={handlePicker}
allowExpandReactions={true}
theme={Theme.DARK}
/>
</div>
)}
</div>
);
};