public group chats

This commit is contained in:
PhilReact 2024-12-16 03:39:41 +02:00
parent c5e29de0e9
commit f481dee813
17 changed files with 638 additions and 293 deletions

View File

@ -100,6 +100,47 @@ transition: all 0.2s;
` `
interface CustomButtonProps {
bgColor?: string;
color?: string;
}
export const CustomButtonAccept = styled(Box)<CustomButtonProps>(
({ bgColor, color }) => ({
boxSizing: "border-box",
padding: "15px 20px",
gap: "10px",
border: "0.5px solid rgba(255, 255, 255, 0.5)",
filter: "drop-shadow(1px 4px 10.5px rgba(0,0,0,0.3))",
borderRadius: 5,
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
width: "fit-content",
transition: "all 0.2s",
minWidth: 160,
cursor: "pointer",
fontWeight: 600,
fontFamily: "Inter",
textAlign: "center",
opacity: 0.7,
// Use the passed-in props or fallback defaults
backgroundColor: bgColor || "transparent",
color: color || "white",
"&:hover": {
opacity: 1,
backgroundColor: bgColor
? bgColor
: "rgba(41, 41, 43, 1)", // fallback hover bg
color: color || "white",
svg: {
path: {
fill: color || "white",
},
},
},
})
);
export const CustomButton = styled(Box)` export const CustomButton = styled(Box)`
/* Authenticate */ /* Authenticate */

View File

@ -62,6 +62,7 @@ import {
AuthenticatedContainerInnerLeft, AuthenticatedContainerInnerLeft,
AuthenticatedContainerInnerRight, AuthenticatedContainerInnerRight,
CustomButton, CustomButton,
CustomButtonAccept,
CustomInput, CustomInput,
CustomLabel, CustomLabel,
TextItalic, TextItalic,
@ -436,6 +437,8 @@ function App() {
const [isOpenSendQortSuccess, setIsOpenSendQortSuccess] = useState(false); const [isOpenSendQortSuccess, setIsOpenSendQortSuccess] = useState(false);
const [rootHeight, setRootHeight] = useState("100%"); const [rootHeight, setRootHeight] = useState("100%");
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [showSeed, setShowSeed] = useState(false)
const [creationStep, setCreationStep] = useState(1)
const qortalRequestCheckbox1Ref = useRef(null); const qortalRequestCheckbox1Ref = useRef(null);
useRetrieveDataLocalStorage(); useRetrieveDataLocalStorage();
useQortalGetSaveSettings(userInfo?.name); useQortalGetSaveSettings(userInfo?.name);
@ -1095,6 +1098,8 @@ function App() {
setExtstate("authenticated"); setExtstate("authenticated");
setIsOpenSendQort(false); setIsOpenSendQort(false);
setIsOpenSendQortSuccess(false); setIsOpenSendQortSuccess(false);
setShowSeed(false)
setCreationStep(1)
}; };
const resetAllStates = () => { const resetAllStates = () => {
@ -1124,6 +1129,8 @@ function App() {
setTxList([]); setTxList([]);
setMemberGroups([]); setMemberGroups([]);
resetAllRecoil(); resetAllRecoil();
setShowSeed(false)
setCreationStep(1)
}; };
function roundUpToDecimals(number, decimals = 8) { function roundUpToDecimals(number, decimals = 8) {
@ -2534,7 +2541,15 @@ await showInfo({
cursor: "pointer", cursor: "pointer",
}} }}
onClick={() => { onClick={() => {
if(creationStep === 2){
setCreationStep(1)
return
}
setExtstate("not-authenticated"); setExtstate("not-authenticated");
setShowSeed(false)
setCreationStep(1)
setWalletToBeDownloadedPasswordConfirm('')
setWalletToBeDownloadedPassword('')
}} }}
src={Return} src={Return}
/> />
@ -2567,32 +2582,110 @@ await showInfo({
padding: '10px' padding: '10px'
}}> }}>
<Box sx={{ <Box sx={{
display: 'flex', display: creationStep === 1 ? 'flex' : 'none',
flexDirection: 'column', flexDirection: 'column',
maxWidth: '400px', width: '350px',
alignItems: 'center', maxWidth: '95%',
gap: '10px' alignItems: 'center'
}}> }}>
<Typography sx={{ <Typography sx={{
fontSize: '14px' fontSize: '14px'
}}>Your seedphrase</Typography> }}>
A <span onClick={()=> {
setShowSeed(true)
}} style={{
fontSize: '14px',
color: 'steelblue',
cursor: 'pointer'
}}>SEEDPHRASE</span> has been randomly generated in the background.
</Typography>
<Typography sx={{ <Typography sx={{
fontSize: '12px' fontSize: '14px',
}}>Only shown once! Please copy and keep safe!</Typography> marginTop: '5px'
}}>
If you wish to VIEW THE SEEDPHRASE, click the word 'SEEDPHRASE' in this text. Seedphrases are used to generate the private key for your Qortal account. For security by default, seedphrases are NOT displayed unless specifically chosen.
</Typography>
<Typography sx={{
fontSize: '16px',
marginTop: '15px',
textAlign: 'center'
}}>
Create your Qortal account by clicking <span style={{
fontWeight: 'bold'
}}>NEXT</span> below.
</Typography>
<Spacer height="17px" />
<CustomButton onClick={()=> {
setCreationStep(2)
}}>
Next
</CustomButton>
</Box>
<div style={{
display: 'none'
}}>
<random-sentence-generator <random-sentence-generator
ref={generatorRef} ref={generatorRef}
template="adverb verb noun adjective noun adverb verb noun adjective noun adjective verbed adjective noun" template="adverb verb noun adjective noun adverb verb noun adjective noun adjective verbed adjective noun"
></random-sentence-generator> ></random-sentence-generator>
</div>
<Dialog
open={showSeed}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogContent>
<Box sx={{
flexDirection: 'column',
maxWidth: '400px',
alignItems: 'center',
gap: '10px',
display: showSeed ? 'flex' : 'none'
}}>
<Typography sx={{
fontSize: '14px'
}}>Your seedphrase</Typography>
<Box sx={{
textAlign: 'center',
width: '100%',
backgroundColor: '#1f2023',
borderRadius: '5px',
padding: '10px',
}}>
{generatorRef.current?.parsedString}
</Box> </Box>
</Box>
<CustomButton sx={{ <CustomButton sx={{
padding: '7px', padding: '7px',
fontSize: '12px' fontSize: '12px'
}} onClick={exportSeedphrase}> }} onClick={exportSeedphrase}>
Export Seedphrase Export Seedphrase
</CustomButton> </CustomButton>
</Box>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={()=> setShowSeed(false)}>
close
</Button>
</DialogActions>
</Dialog>
</Box>
<Box sx={{
display: creationStep === 2 ? 'flex' : 'none',
flexDirection: 'column',
alignItems: 'center',
}}>
<Spacer height="14px" /> <Spacer height="14px" />
<CustomLabel htmlFor="standard-adornment-password"> <CustomLabel htmlFor="standard-adornment-password">
Wallet Password Wallet Password
@ -2622,6 +2715,7 @@ await showInfo({
<CustomButton onClick={createAccountFunc}> <CustomButton onClick={createAccountFunc}>
Create Account Create Account
</CustomButton> </CustomButton>
</Box>
<ErrorText>{walletToBeDownloadedError}</ErrorText> <ErrorText>{walletToBeDownloadedError}</ErrorText>
</> </>
)} )}
@ -2776,11 +2870,29 @@ await showInfo({
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button variant="contained" onClick={onCancel}> <Button sx={{
Disagree backgroundColor: 'var(--green)',
color: 'black',
opacity: 0.7,
'&:hover': {
backgroundColor: 'var(--green)',
color: 'black',
opacity: 1
},
}} variant="contained" onClick={onOk} autoFocus>
accept
</Button> </Button>
<Button variant="contained" onClick={onOk} autoFocus> <Button sx={{
Agree backgroundColor: 'var(--unread)',
color: 'black',
opacity: 0.7,
'&:hover': {
backgroundColor: 'var(--unread)',
color: 'black',
opacity: 1
},
}} variant="contained" onClick={onCancel}>
decline
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
@ -3051,22 +3163,26 @@ await showInfo({
gap: "14px", gap: "14px",
}} }}
> >
<CustomButton <CustomButtonAccept
color="black"
bgColor="var(--green)"
sx={{ sx={{
minWidth: "102px", minWidth: "102px",
}} }}
onClick={() => onOkQortalRequestExtension("accepted")} onClick={() => onOkQortalRequestExtension("accepted")}
> >
accept accept
</CustomButton> </CustomButtonAccept>
<CustomButton <CustomButtonAccept
color="black"
bgColor="var(--unread)"
sx={{ sx={{
minWidth: "102px", minWidth: "102px",
}} }}
onClick={() => onCancelQortalRequestExtension()} onClick={() => onCancelQortalRequestExtension()}
> >
decline decline
</CustomButton> </CustomButtonAccept>
</Box> </Box>
<ErrorText>{sendPaymentError}</ErrorText> <ErrorText>{sendPaymentError}</ErrorText>
</Box> </Box>

View File

@ -92,21 +92,6 @@ export const MessageQueueProvider = ({ children }) => {
// Remove the message from the queue after successful sending // Remove the message from the queue after successful sending
messageQueueRef.current.shift(); messageQueueRef.current.shift();
// Remove the message from queueChats
setQueueChats((prev) => {
const updatedChats = { ...prev };
if (updatedChats[groupDirectId]) {
updatedChats[groupDirectId] = updatedChats[groupDirectId].filter(
(item) => item.identifier !== identifier
);
// If no more chats for this group, delete the groupDirectId entry
if (updatedChats[groupDirectId].length === 0) {
delete updatedChats[groupDirectId];
}
}
return updatedChats;
});
} catch (error) { } catch (error) {
console.error('Message sending failed', error); console.error('Message sending failed', error);
@ -142,15 +127,25 @@ export const MessageQueueProvider = ({ children }) => {
// Method to process with new messages and groupDirectId // Method to process with new messages and groupDirectId
const processWithNewMessages = (newMessages, groupDirectId) => { const processWithNewMessages = (newMessages, groupDirectId) => {
let updatedNewMessages = newMessages
if (newMessages.length > 0) { if (newMessages.length > 0) {
messageQueueRef.current = messageQueueRef.current.filter((msg) => {
return !newMessages.some(newMsg => newMsg?.specialId === msg?.specialId);
});
// Remove corresponding entries in queueChats for the provided groupDirectId // Remove corresponding entries in queueChats for the provided groupDirectId
setQueueChats((prev) => { setQueueChats((prev) => {
const updatedChats = { ...prev }; const updatedChats = { ...prev };
if (updatedChats[groupDirectId]) { if (updatedChats[groupDirectId]) {
updatedNewMessages = newMessages?.map((msg)=> {
const findTempMsg = updatedChats[groupDirectId]?.find((msg2)=> msg2?.message?.specialId === msg?.specialId)
if(findTempMsg){
return {
...msg,
tempSignature: findTempMsg?.signature
}
}
return msg
})
updatedChats[groupDirectId] = updatedChats[groupDirectId].filter((chat) => { updatedChats[groupDirectId] = updatedChats[groupDirectId].filter((chat) => {
return !newMessages.some(newMsg => newMsg?.specialId === chat?.message?.specialId); return !newMessages.some(newMsg => newMsg?.specialId === chat?.message?.specialId);
}); });
@ -167,8 +162,23 @@ export const MessageQueueProvider = ({ children }) => {
} }
return updatedChats; return updatedChats;
}); });
}
setTimeout(() => {
if(!messageQueueRef.current.find((msg) => msg?.groupDirectId === groupDirectId)){
setQueueChats((prev) => {
const updatedChats = { ...prev };
if (updatedChats[groupDirectId]) {
delete updatedChats[groupDirectId]
} }
return updatedChats
}
)
}
}, 300);
return updatedNewMessages
}; };
return ( return (

View File

@ -608,8 +608,7 @@ const handleNotification = async (groups) => {
const data = groups.filter( const data = groups.filter(
(group) => (group) =>
group?.sender !== address && group?.sender !== address &&
!mutedGroups.includes(group.groupId) && !mutedGroups.includes(group.groupId)
!isUpdateMsg(group?.data)
); );
const dataWithUpdates = groups.filter( const dataWithUpdates = groups.filter(
(group) => group?.sender !== address && !mutedGroups.includes(group.groupId) (group) => group?.sender !== address && !mutedGroups.includes(group.groupId)
@ -657,8 +656,7 @@ const handleNotification = async (groups) => {
Date.now() - lastGroupNotification >= 120000 Date.now() - lastGroupNotification >= 120000
) { ) {
if ( if (
!newestLatestTimestamp?.data || !newestLatestTimestamp?.data
!isExtMsg(newestLatestTimestamp?.data)
) return; ) return;
const notificationId = generateId() const notificationId = generateId()

View File

@ -22,7 +22,7 @@ import { useRecoilState, useSetRecoilState } from "recoil";
import { settingsLocalLastUpdatedAtom, sortablePinnedAppsAtom } from "../../atoms/global"; import { settingsLocalLastUpdatedAtom, sortablePinnedAppsAtom } from "../../atoms/global";
import { saveToLocalStorage } from "./AppsNavBar"; import { saveToLocalStorage } from "./AppsNavBar";
export const AppInfoSnippet = ({ app, myName, isFromCategory }) => { export const AppInfoSnippet = ({ app, myName, isFromCategory, parentStyles = {} }) => {
const isInstalled = app?.status?.status === 'READY' const isInstalled = app?.status?.status === 'READY'
const [sortablePinnedApps, setSortablePinnedApps] = useRecoilState(sortablePinnedAppsAtom); const [sortablePinnedApps, setSortablePinnedApps] = useRecoilState(sortablePinnedAppsAtom);
@ -30,7 +30,9 @@ export const AppInfoSnippet = ({ app, myName, isFromCategory }) => {
const isSelectedAppPinned = !!sortablePinnedApps?.find((item)=> item?.name === app?.name && item?.service === app?.service) const isSelectedAppPinned = !!sortablePinnedApps?.find((item)=> item?.name === app?.name && item?.service === app?.service)
const setSettingsLocalLastUpdated = useSetRecoilState(settingsLocalLastUpdatedAtom); const setSettingsLocalLastUpdated = useSetRecoilState(settingsLocalLastUpdatedAtom);
return ( return (
<AppInfoSnippetContainer> <AppInfoSnippetContainer sx={{
...parentStyles
}}>
<AppInfoSnippetLeft> <AppInfoSnippetLeft>
<ButtonBase <ButtonBase
sx={{ sx={{

View File

@ -86,6 +86,8 @@ export const AppsCategory = ({ availableQapps, myName, category, isShow }) =>
const categoryList = useMemo(() => { const categoryList = useMemo(() => {
if(category?.id === 'all') return availableQapps
return availableQapps.filter( return availableQapps.filter(
(app) => (app) =>
app?.metadata?.category === category?.id app?.metadata?.category === category?.id
@ -99,7 +101,11 @@ export const AppsCategory = ({ availableQapps, myName, category, isShow }) =>
const handler = setTimeout(() => { const handler = setTimeout(() => {
setDebouncedValue(searchValue); setDebouncedValue(searchValue);
}, 350); }, 350);
setTimeout(() => {
virtuosoRef.current.scrollToIndex({
index: 0
});
}, 500);
// Cleanup timeout if searchValue changes before the timeout completes // Cleanup timeout if searchValue changes before the timeout completes
return () => { return () => {
clearTimeout(handler); clearTimeout(handler);
@ -111,7 +117,7 @@ export const AppsCategory = ({ availableQapps, myName, category, isShow }) =>
const searchedList = useMemo(() => { const searchedList = useMemo(() => {
if (!debouncedValue) return categoryList if (!debouncedValue) return categoryList
return categoryList.filter((app) => return categoryList.filter((app) =>
app.name.toLowerCase().includes(debouncedValue.toLowerCase()) app.name.toLowerCase().includes(debouncedValue.toLowerCase()) || (app?.metadata?.title && app?.metadata?.title?.toLowerCase().includes(debouncedValue.toLowerCase()))
); );
}, [debouncedValue, categoryList]); }, [debouncedValue, categoryList]);

View File

@ -17,7 +17,7 @@ import {
PublishQAppCTARight, PublishQAppCTARight,
PublishQAppDotsBG, PublishQAppDotsBG,
} from "./Apps-styles"; } from "./Apps-styles";
import { Avatar, Box, ButtonBase, InputBase, styled } from "@mui/material"; import { Avatar, Box, ButtonBase, InputBase, Typography, styled } from "@mui/material";
import { Add } from "@mui/icons-material"; import { Add } from "@mui/icons-material";
import { MyContext, getBaseApiReact } from "../../App"; import { MyContext, getBaseApiReact } from "../../App";
import LogoSelected from "../../assets/svgs/LogoSelected.svg"; import LogoSelected from "../../assets/svgs/LogoSelected.svg";
@ -101,7 +101,11 @@ export const AppsLibrary = ({ availableQapps, setMode, myName, hasPublishApp, i
const handler = setTimeout(() => { const handler = setTimeout(() => {
setDebouncedValue(searchValue); setDebouncedValue(searchValue);
}, 350); }, 350);
setTimeout(() => {
virtuosoRef.current.scrollToIndex({
index: 0
});
}, 500);
// Cleanup timeout if searchValue changes before the timeout completes // Cleanup timeout if searchValue changes before the timeout completes
return () => { return () => {
clearTimeout(handler); clearTimeout(handler);
@ -113,7 +117,7 @@ export const AppsLibrary = ({ availableQapps, setMode, myName, hasPublishApp, i
const searchedList = useMemo(() => { const searchedList = useMemo(() => {
if (!debouncedValue) return []; if (!debouncedValue) return [];
return availableQapps.filter((app) => return availableQapps.filter((app) =>
app.name.toLowerCase().includes(debouncedValue.toLowerCase()) app.name.toLowerCase().includes(debouncedValue.toLowerCase()) || (app?.metadata?.title && app?.metadata?.title?.toLowerCase().includes(debouncedValue.toLowerCase()))
); );
}, [debouncedValue]); }, [debouncedValue]);
@ -214,6 +218,10 @@ export const AppsLibrary = ({ availableQapps, setMode, myName, hasPublishApp, i
/> />
</StyledVirtuosoContainer> </StyledVirtuosoContainer>
</AppsWidthLimiter> </AppsWidthLimiter>
) : searchedList?.length === 0 && debouncedValue ? (
<AppsWidthLimiter>
<Typography>No results</Typography>
</AppsWidthLimiter>
) : ( ) : (
<> <>
<AppsWidthLimiter> <AppsWidthLimiter>
@ -313,6 +321,33 @@ export const AppsLibrary = ({ availableQapps, setMode, myName, hasPublishApp, i
// Hide scrollbar for IE and older Edge // Hide scrollbar for IE and older Edge
"-ms-overflow-style": "none", "-ms-overflow-style": "none",
}}> }}>
<ButtonBase
onClick={() => {
executeEvent("selectedCategory", {
data: {
id: 'all',
name: 'All'
},
});
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '110px',
width: '110px',
background: 'linear-gradient(163.47deg, #4BBCFE 27.55%, #1386C9 86.56%)',
color: '#1D1D1E',
fontWeight: 700,
fontSize: '16px',
flexShrink: 0,
borderRadius: '11px'
}}>
All
</Box>
</ButtonBase>
{categories?.map((category)=> { {categories?.map((category)=> {
return ( return (
<ButtonBase key={category?.id} onClick={()=> { <ButtonBase key={category?.id} onClick={()=> {

View File

@ -116,9 +116,9 @@ export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDi
data: encryptedMessages, data: encryptedMessages,
involvingAddress: selectedDirect?.address, involvingAddress: selectedDirect?.address,
}) })
.then((response) => { .then((decryptResponse) => {
if (!response?.error) { if (!decryptResponse?.error) {
processWithNewMessages(response, selectedDirect?.address); const response = processWithNewMessages(decryptResponse, selectedDirect?.address);
res(response); res(response);
if (isInitiated) { if (isInitiated) {
@ -366,7 +366,7 @@ useEffect(() => {
const htmlContent = editorRef?.current.getHTML(); const htmlContent = editorRef?.current.getHTML();
const stringified = JSON.stringify(htmlContent); const stringified = JSON.stringify(htmlContent);
const size = new Blob([stringified]).size; const size = new Blob([stringified]).size;
setMessageSize(size + 100); setMessageSize(size + 200);
}; };
// Add a listener for the editorRef?.current's content updates // Add a listener for the editorRef?.current's content updates
@ -381,7 +381,7 @@ useEffect(() => {
const sendMessage = async ()=> { const sendMessage = async ()=> {
try { try {
if(messageSize > 4000) return
if(+balance < 4) throw new Error('You need at least 4 QORT to send a message') if(+balance < 4) throw new Error('You need at least 4 QORT to send a message')
if(isSending) return if(isSending) return

View File

@ -31,7 +31,7 @@ import { throttle } from 'lodash'
const uid = new ShortUniqueId({ length: 5 }); const uid = new ShortUniqueId({ length: 5 });
export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, myAddress, handleNewEncryptionNotification, hide, handleSecretKeyCreationInProgress, triedToFetchSecretKey, myName, balance, getTimestampEnterChatParent}) => { export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, myAddress, handleNewEncryptionNotification, hide, handleSecretKeyCreationInProgress, triedToFetchSecretKey, myName, balance, getTimestampEnterChatParent, isPrivate}) => {
const [messages, setMessages] = useState([]) const [messages, setMessages] = useState([])
const [chatReferences, setChatReferences] = useState({}) const [chatReferences, setChatReferences] = useState({})
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
@ -191,7 +191,6 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
try { try {
if(!secretKeyRef.current){ if(!secretKeyRef.current){
checkForFirstSecretKeyNotification(encryptedMessages) checkForFirstSecretKeyNotification(encryptedMessages)
return
} }
return new Promise((res, rej)=> { return new Promise((res, rej)=> {
window.sendMessage("decryptSingle", { window.sendMessage("decryptSingle", {
@ -203,9 +202,9 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
const filterUIMessages = encryptedMessages.filter((item) => !isExtMsg(item.data)); const filterUIMessages = encryptedMessages.filter((item) => !isExtMsg(item.data));
const decodedUIMessages = decodeBase64ForUIChatMessages(filterUIMessages); const decodedUIMessages = decodeBase64ForUIChatMessages(filterUIMessages);
const combineUIAndExtensionMsgs = [...decodedUIMessages, ...response]; const combineUIAndExtensionMsgsBefore = [...decodedUIMessages, ...response];
processWithNewMessages( const combineUIAndExtensionMsgs = processWithNewMessages(
combineUIAndExtensionMsgs.map((item) => ({ combineUIAndExtensionMsgsBefore.map((item) => ({
...item, ...item,
...(item?.decryptedData || {}), ...(item?.decryptedData || {}),
})), })),
@ -233,7 +232,7 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
setChatReferences((prev) => { setChatReferences((prev) => {
const organizedChatReferences = { ...prev }; const organizedChatReferences = { ...prev };
combineUIAndExtensionMsgs combineUIAndExtensionMsgs
.filter((rawItem) => rawItem && rawItem.chatReference && (rawItem.decryptedData?.type === "reaction" || rawItem.decryptedData?.type === "edit")) .filter((rawItem) => rawItem && rawItem.chatReference && (rawItem.decryptedData?.type === "reaction" || rawItem.decryptedData?.type === "edit" || rawItem?.type === "edit" || rawItem?.type === "reaction"))
.forEach((item) => { .forEach((item) => {
try { try {
if(item.decryptedData?.type === "edit"){ if(item.decryptedData?.type === "edit"){
@ -241,11 +240,16 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
...(organizedChatReferences[item.chatReference] || {}), ...(organizedChatReferences[item.chatReference] || {}),
edit: item.decryptedData, edit: item.decryptedData,
}; };
} else if(item?.type === "edit"){
organizedChatReferences[item.chatReference] = {
...(organizedChatReferences[item.chatReference] || {}),
edit: item,
};
} else { } else {
const content = item.decryptedData?.content; const content = item?.content || item.decryptedData?.content;
const sender = item.sender; const sender = item.sender;
const newTimestamp = item.timestamp; const newTimestamp = item.timestamp;
const contentState = item.decryptedData?.contentState; const contentState = item?.contentState || item.decryptedData?.contentState;
if (!content || typeof content !== "string" || !sender || typeof sender !== "string" || !newTimestamp) { if (!content || typeof content !== "string" || !sender || typeof sender !== "string" || !newTimestamp) {
console.warn("Invalid content, sender, or timestamp in reaction data", item); console.warn("Invalid content, sender, or timestamp in reaction data", item);
@ -316,7 +320,7 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
const organizedChatReferences = { ...prev }; const organizedChatReferences = { ...prev };
combineUIAndExtensionMsgs combineUIAndExtensionMsgs
.filter((rawItem) => rawItem && rawItem.chatReference && (rawItem.decryptedData?.type === "reaction" || rawItem.decryptedData?.type === "edit")) .filter((rawItem) => rawItem && rawItem.chatReference && (rawItem.decryptedData?.type === "reaction" || rawItem.decryptedData?.type === "edit" || rawItem?.type === "edit" || rawItem?.type === "reaction"))
.forEach((item) => { .forEach((item) => {
try { try {
if(item.decryptedData?.type === "edit"){ if(item.decryptedData?.type === "edit"){
@ -324,11 +328,16 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
...(organizedChatReferences[item.chatReference] || {}), ...(organizedChatReferences[item.chatReference] || {}),
edit: item.decryptedData, edit: item.decryptedData,
}; };
} else if(item?.type === "edit"){
organizedChatReferences[item.chatReference] = {
...(organizedChatReferences[item.chatReference] || {}),
edit: item,
};
} else { } else {
const content = item.decryptedData?.content; const content = item?.content || item.decryptedData?.content;
const sender = item.sender; const sender = item.sender;
const newTimestamp = item.timestamp; const newTimestamp = item.timestamp;
const contentState = item.decryptedData?.contentState; const contentState = item?.contentState || item.decryptedData?.contentState;
if (!content || typeof content !== "string" || !sender || typeof sender !== "string" || !newTimestamp) { if (!content || typeof content !== "string" || !sender || typeof sender !== "string" || !newTimestamp) {
console.warn("Invalid content, sender, or timestamp in reaction data", item); console.warn("Invalid content, sender, or timestamp in reaction data", item);
@ -463,10 +472,11 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
setIsLoading(true) setIsLoading(true)
initWebsocketMessageGroup() initWebsocketMessageGroup()
} }
}, [triedToFetchSecretKey, secretKey]) }, [triedToFetchSecretKey, secretKey, isPrivate])
useEffect(()=> { useEffect(()=> {
if(!secretKey || hasInitializedWebsocket.current) return if(isPrivate === null) return
if(isPrivate === false || !secretKey || hasInitializedWebsocket.current) return
forceCloseWebSocket() forceCloseWebSocket()
setMessages([]) setMessages([])
setIsLoading(true) setIsLoading(true)
@ -476,18 +486,33 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
}, 6000); }, 6000);
initWebsocketMessageGroup() initWebsocketMessageGroup()
hasInitializedWebsocket.current = true hasInitializedWebsocket.current = true
}, [secretKey]) }, [secretKey, isPrivate])
useEffect(() => { useEffect(() => {
if (!editorRef?.current) return; if (!editorRef?.current) return;
handleUpdateRef.current = throttle(() => { handleUpdateRef.current = throttle(async () => {
try {
if(isPrivate){
const htmlContent = editorRef.current.getHTML(); const htmlContent = editorRef.current.getHTML();
const size = new TextEncoder().encode(htmlContent).length; const message64 = await objectToBase64(JSON.stringify(htmlContent))
setMessageSize(size + 100); const secretKeyObject = await getSecretKey(false, true)
const encryptSingle = await encryptChatMessage(message64, secretKeyObject)
setMessageSize((encryptSingle?.length || 0) + 200);
} else {
const htmlContent = editorRef.current.getJSON();
const message = JSON.stringify(htmlContent)
const size = new Blob([message]).size
setMessageSize(size + 300);
}
} catch (error) {
// calc size error
}
}, 1200); }, 1200);
const currentEditor = editorRef.current; const currentEditor = editorRef.current;
currentEditor.on("update", handleUpdateRef.current); currentEditor.on("update", handleUpdateRef.current);
@ -495,7 +520,9 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
return () => { return () => {
currentEditor.off("update", handleUpdateRef.current); currentEditor.off("update", handleUpdateRef.current);
}; };
}, [editorRef, setMessageSize]); }, [editorRef, setMessageSize, isFocusedParent, isPrivate]);
useEffect(()=> { useEffect(()=> {
@ -587,6 +614,8 @@ const clearEditorContent = () => {
const sendMessage = async ()=> { const sendMessage = async ()=> {
try { try {
if(messageSize > 4000) return
if(isPrivate === null) throw new Error('Unable to determine if group is private')
if(isSending) return if(isSending) return
if(+balance < 4) throw new Error('You need at least 4 QORT to send a message') if(+balance < 4) throw new Error('You need at least 4 QORT to send a message')
pauseAllQueues() pauseAllQueues()
@ -594,8 +623,10 @@ const sendMessage = async ()=> {
const htmlContent = editorRef.current.getHTML(); const htmlContent = editorRef.current.getHTML();
if(!htmlContent?.trim() || htmlContent?.trim() === '<p></p>') return if(!htmlContent?.trim() || htmlContent?.trim() === '<p></p>') return
setIsSending(true) setIsSending(true)
const message = htmlContent const message = isPrivate === false ? editorRef.current.getJSON() : htmlContent
const secretKeyObject = await getSecretKey(false, true) const secretKeyObject = await getSecretKey(false, true)
let repliedTo = replyMessage?.signature let repliedTo = replyMessage?.signature
@ -605,19 +636,24 @@ const sendMessage = async ()=> {
} }
let chatReference = onEditMessage?.signature let chatReference = onEditMessage?.signature
const publicData = isPrivate ? {} : {
isEdited : chatReference ? true : false,
}
const otherData = { const otherData = {
repliedTo, repliedTo,
...(onEditMessage?.decryptedData || {}), ...(onEditMessage?.decryptedData || {}),
type: chatReference ? 'edit' : '', type: chatReference ? 'edit' : '',
specialId: uid.rnd(), specialId: uid.rnd(),
...publicData
} }
const objectMessage = { const objectMessage = {
...(otherData || {}), ...(otherData || {}),
message [isPrivate ? 'message' : 'messageText']: message,
version: 3
} }
const message64: any = await objectToBase64(objectMessage) const message64: any = await objectToBase64(objectMessage)
const encryptSingle = await encryptChatMessage(message64, secretKeyObject) const encryptSingle = isPrivate === false ? JSON.stringify(objectMessage) : await encryptChatMessage(message64, secretKeyObject)
// const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle}) // const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle})
const sendMessageFunc = async () => { const sendMessageFunc = async () => {
@ -627,7 +663,7 @@ const sendMessage = async ()=> {
// Add the function to the queue // Add the function to the queue
const messageObj = { const messageObj = {
message: { message: {
text: message, text: htmlContent,
timestamp: Date.now(), timestamp: Date.now(),
senderName: myName, senderName: myName,
sender: myAddress, sender: myAddress,
@ -687,10 +723,8 @@ const sendMessage = async ()=> {
setReplyMessage(null) setReplyMessage(null)
setIsFocusedParent(true); setIsFocusedParent(true);
setTimeout(() => { setTimeout(() => {
editorRef.current.chain().focus().setContent(message?.text).run(); editorRef.current.chain().focus().setContent(message?.messageText || message?.text).run();
}, 250); }, 250);
}, []) }, [])
const handleReaction = useCallback(async (reaction, chatMessage, reactionState = true)=> { const handleReaction = useCallback(async (reaction, chatMessage, reactionState = true)=> {
@ -718,7 +752,7 @@ const sendMessage = async ()=> {
} }
const message64: any = await objectToBase64(objectMessage) const message64: any = await objectToBase64(objectMessage)
const reactiontypeNumber = RESOURCE_TYPE_NUMBER_GROUP_CHAT_REACTIONS const reactiontypeNumber = RESOURCE_TYPE_NUMBER_GROUP_CHAT_REACTIONS
const encryptSingle = await encryptChatMessage(message64, secretKeyObject, reactiontypeNumber) const encryptSingle = isPrivate === false ? JSON.stringify(objectMessage) : await encryptChatMessage(message64, secretKeyObject, reactiontypeNumber)
// const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle}) // const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle})
const sendMessageFunc = async () => { const sendMessageFunc = async () => {
@ -770,7 +804,7 @@ const sendMessage = async ()=> {
left: hide && '-100000px', left: hide && '-100000px',
}}> }}>
<ChatList enableMentions onReply={onReply} onEdit={onEdit} chatId={selectedGroup} initialMessages={messages} myAddress={myAddress} tempMessages={tempMessages} handleReaction={handleReaction} chatReferences={chatReferences} tempChatReferences={tempChatReferences} members={members} myName={myName} selectedGroup={selectedGroup}/> <ChatList isPrivate={isPrivate} enableMentions onReply={onReply} onEdit={onEdit} chatId={selectedGroup} initialMessages={messages} myAddress={myAddress} tempMessages={tempMessages} handleReaction={handleReaction} chatReferences={chatReferences} tempChatReferences={tempChatReferences} members={members} myName={myName} selectedGroup={selectedGroup}/>
<div style={{ <div style={{
@ -836,7 +870,7 @@ const sendMessage = async ()=> {
<div style={{ <div style={{
display: isFocusedParent ? 'none' : 'block' display: isFocusedParent ? 'none' : 'block'
}}> }}>
<ChatOptions openQManager={openQManager} <ChatOptions isPrivate={isPrivate} openQManager={openQManager}
messages={messages} goToMessage={()=> {}} members={members} myName={myName} selectedGroup={selectedGroup}/> messages={messages} goToMessage={()=> {}} members={members} myName={myName} selectedGroup={selectedGroup}/>
</div> </div>
</Box> </Box>
@ -878,7 +912,6 @@ const sendMessage = async ()=> {
{isFocusedParent && ( {isFocusedParent && (
<CustomButton <CustomButton
onClick={()=> { onClick={()=> {
if(messageSize > 4000) return
if(isSending) return if(isSending) return
sendMessage() sendMessage()
}} }}

View File

@ -6,7 +6,7 @@ import { useInView } from 'react-intersection-observer'
import { Typography } from '@mui/material'; import { Typography } from '@mui/material';
import ErrorBoundary from '../../common/ErrorBoundary'; import ErrorBoundary from '../../common/ErrorBoundary';
export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onReply, handleReaction, chatReferences, tempChatReferences, onEdit export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onReply, handleReaction, chatReferences, tempChatReferences, isPrivate, onEdit
}) => { }) => {
const parentRef = useRef(); const parentRef = useRef();
const [messages, setMessages] = useState(initialMessages); const [messages, setMessages] = useState(initialMessages);
@ -20,7 +20,7 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR
// Initialize the virtualizer // Initialize the virtualizer
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: messages.length, count: messages.length,
getItemKey: (index) => messages[index].signature, getItemKey: (index) => messages[index]?.tempSignature || messages[index].signature,
getScrollElement: () => parentRef?.current, getScrollElement: () => parentRef?.current,
estimateSize: useCallback(() => 80, []), // Provide an estimated height of items, adjust this as needed estimateSize: useCallback(() => 80, []), // Provide an estimated height of items, adjust this as needed
overscan: 10, // Number of items to render outside the visible area to improve smoothness overscan: 10, // Number of items to render outside the visible area to improve smoothness
@ -264,7 +264,10 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR
message.text = chatReferences[message.signature]?.edit?.message; message.text = chatReferences[message.signature]?.edit?.message;
message.isEdit = true message.isEdit = true
} }
if (chatReferences[message.signature]?.edit?.messageText && message?.messageText) {
message.messageText = chatReferences[message.signature]?.edit?.messageText;
message.isEdit = true
}
} }
@ -348,6 +351,7 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR
handleReaction={handleReaction} handleReaction={handleReaction}
reactions={reactions} reactions={reactions}
isUpdating={isUpdating} isUpdating={isUpdating}
isPrivate={isPrivate}
/> />
</ErrorBoundary> </ErrorBoundary>
</div> </div>

View File

@ -33,6 +33,12 @@ import { ContextMenuMentions } from "../ContextMenuMentions";
import { convert } from 'html-to-text'; import { convert } from 'html-to-text';
import { executeEvent } from "../../utils/events"; import { executeEvent } from "../../utils/events";
import InsertLinkIcon from '@mui/icons-material/InsertLink'; import InsertLinkIcon from '@mui/icons-material/InsertLink';
import Highlight from "@tiptap/extension-highlight";
import Mention from "@tiptap/extension-mention";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import { generateHTML } from "@tiptap/react";
import ErrorBoundary from "../../common/ErrorBoundary";
const extractTextFromHTML = (htmlString = '') => { const extractTextFromHTML = (htmlString = '') => {
return convert(htmlString, { return convert(htmlString, {
@ -44,7 +50,7 @@ const cache = new CellMeasurerCache({
defaultHeight: 50, defaultHeight: 50,
}); });
export const ChatOptions = ({ messages, goToMessage, members, myName, selectedGroup, openQManager }) => { export const ChatOptions = ({ messages : untransformedMessages, goToMessage, members, myName, selectedGroup, openQManager, isPrivate }) => {
const [mode, setMode] = useState("default"); const [mode, setMode] = useState("default");
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
const [selectedMember, setSelectedMember] = useState(0); const [selectedMember, setSelectedMember] = useState(0);
@ -53,6 +59,27 @@ export const ChatOptions = ({ messages, goToMessage, members, myName, selectedGr
const parentRefMentions = useRef(); const parentRefMentions = useRef();
const [lastMentionTimestamp, setLastMentionTimestamp] = useState(null) const [lastMentionTimestamp, setLastMentionTimestamp] = useState(null)
const [debouncedValue, setDebouncedValue] = useState(""); // Debounced value const [debouncedValue, setDebouncedValue] = useState(""); // Debounced value
const messages = useMemo(()=> {
return untransformedMessages?.map((item)=> {
if(item?.messageText){
let transformedMessage = item?.messageText
try {
transformedMessage = generateHTML(item?.messageText, [
StarterKit,
Underline,
Highlight,
Mention
])
return {
...item,
messageText: transformedMessage
}
} catch (error) {
// error
}
} else return item
})
}, [untransformedMessages])
const getTimestampMention = async () => { const getTimestampMention = async () => {
try { try {
@ -125,7 +152,7 @@ export const ChatOptions = ({ messages, goToMessage, members, myName, selectedGr
.filter( .filter(
(message) => (message) =>
message?.senderName === selectedMember && message?.senderName === selectedMember &&
extractTextFromHTML(message?.decryptedData?.message)?.includes( extractTextFromHTML(isPrivate ? message?.messageText : message?.decryptedData?.message)?.includes(
debouncedValue.toLowerCase() debouncedValue.toLowerCase()
) )
) )
@ -133,20 +160,27 @@ export const ChatOptions = ({ messages, goToMessage, members, myName, selectedGr
} }
return messages return messages
.filter((message) => .filter((message) =>
extractTextFromHTML(message?.decryptedData?.message)?.includes(debouncedValue.toLowerCase()) extractTextFromHTML(isPrivate === false ? message?.messageText : message?.decryptedData?.message)?.includes(debouncedValue.toLowerCase())
) )
?.sort((a, b) => b?.timestamp - a?.timestamp); ?.sort((a, b) => b?.timestamp - a?.timestamp);
}, [debouncedValue, messages, selectedMember]); }, [debouncedValue, messages, selectedMember, isPrivate]);
const mentionList = useMemo(() => { const mentionList = useMemo(() => {
if(!messages || messages.length === 0 || !myName) return [] if(!messages || messages.length === 0 || !myName) return []
if(isPrivate === false){
return messages
.filter((message) =>
extractTextFromHTML(message?.messageText)?.includes(`@${myName}`)
)
?.sort((a, b) => b?.timestamp - a?.timestamp);
}
return messages return messages
.filter((message) => .filter((message) =>
extractTextFromHTML(message?.decryptedData?.message)?.includes(`@${myName}`) extractTextFromHTML(message?.decryptedData?.message)?.includes(`@${myName}`)
) )
?.sort((a, b) => b?.timestamp - a?.timestamp); ?.sort((a, b) => b?.timestamp - a?.timestamp);
}, [messages, myName]); }, [messages, myName, isPrivate]);
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: searchedList.length, count: searchedList.length,
@ -297,86 +331,7 @@ export const ChatOptions = ({ messages, goToMessage, members, myName, selectedGr
gap: "5px", gap: "5px",
}} }}
> >
<Box <ShowMessage messages={messages} goToMessage={goToMessage} message={message} setMode={setMode} />
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
padding: "0px 20px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "15px",
}}
>
<Avatar
sx={{
backgroundColor: "#27282c",
color: "white",
height: "25px",
width: "25px",
}}
alt={message?.senderName}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${
message?.senderName
}/qortal_avatar?async=true`}
>
{message?.senderName?.charAt(0)}
</Avatar>
<Typography
sx={{
fontWight: 600,
fontFamily: "Inter",
color: "cadetBlue",
}}
>
{message?.senderName}
</Typography>
</Box>
</Box>
<Spacer height="5px" />
<Typography sx={{
fontSize: '12px'
}}>{formatTimestamp(message.timestamp)}</Typography>
<Box
style={{
cursor: "pointer",
}}
onClick={() => {
const findMsgIndex = messages.findIndex(
(item) =>
item?.signature === message?.signature
);
if (findMsgIndex !== -1) {
if(isMobile){
setMode("default");
executeEvent('goToMessage', {index: findMsgIndex})
} else {
goToMessage(findMsgIndex);
}
}
}}
>
<MessageDisplay
htmlContent={
message?.decryptedData?.message || "<p></p>"
}
/>
</Box>
</Box>
</div> </div>
); );
})} })}
@ -580,6 +535,86 @@ export const ChatOptions = ({ messages, goToMessage, members, myName, selectedGr
gap: "5px", gap: "5px",
}} }}
> >
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<ShowMessage message={message} goToMessage={goToMessage} messages={messages} setMode={setMode} />
</ErrorBoundary>
</div>
);
})}
</div>
</div>
</div>
</div>
</Box>
</Box>
</Box>
);
}
return (
<Box
sx={{
width: isMobile ? 'auto' : "50px",
height: "100%",
gap: "20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Box
sx={{
width: "100%",
padding: "10px",
gap: "20px",
display: "flex",
flexDirection: isMobile ? 'row' : "column",
alignItems: "center",
backgroundColor: isMobile ? 'transparent' :"#1F2023",
borderBottomLeftRadius: "20px",
borderTopLeftRadius: "20px",
minHeight: isMobile ? 'auto' : "200px",
}}
>
<ButtonBase onClick={() => {
setMode("search")
}}>
<SearchIcon />
</ButtonBase>
<ContextMenuMentions getTimestampMention={getTimestampMention} groupId={selectedGroup}>
<ButtonBase onClick={() => {
setMode("mentions")
setSearchValue('')
setSelectedMember(0)
}}>
<AlternateEmailIcon sx={{
color: mentionList?.length > 0 && (!lastMentionTimestamp || lastMentionTimestamp < mentionList[0]?.timestamp) ? 'var(--unread)' : 'white'
}} />
</ButtonBase>
</ContextMenuMentions>
<ButtonBase onClick={() => {
setMode("default")
setSearchValue('')
setSelectedMember(0)
openQManager()
}}>
<InsertLinkIcon sx={{
color: 'white'
}} />
</ButtonBase>
</Box>
</Box>
);
};
const ShowMessage = ({message, goToMessage, messages, setMode})=> {
return (
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@ -653,77 +688,19 @@ export const ChatOptions = ({ messages, goToMessage, members, myName, selectedGr
} }
}} }}
> >
{message?.messageText && (
<MessageDisplay
htmlContent={message?.messageText}
/>
)}
{message?.decryptedData?.message && (
<MessageDisplay <MessageDisplay
htmlContent={ htmlContent={
message?.decryptedData?.message || "<p></p>" message?.decryptedData?.message || "<p></p>"
} }
/> />
)}
</Box> </Box>
</Box> </Box>
</div> )
);
})}
</div>
</div>
</div>
</div>
</Box>
</Box>
</Box>
);
} }
return (
<Box
sx={{
width: isMobile ? 'auto' : "50px",
height: "100%",
gap: "20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Box
sx={{
width: "100%",
padding: "10px",
gap: "20px",
display: "flex",
flexDirection: isMobile ? 'row' : "column",
alignItems: "center",
backgroundColor: isMobile ? 'transparent' :"#1F2023",
borderBottomLeftRadius: "20px",
borderTopLeftRadius: "20px",
minHeight: isMobile ? 'auto' : "200px",
}}
>
<ButtonBase onClick={() => {
setMode("search")
}}>
<SearchIcon />
</ButtonBase>
<ContextMenuMentions getTimestampMention={getTimestampMention} groupId={selectedGroup}>
<ButtonBase onClick={() => {
setMode("mentions")
setSearchValue('')
setSelectedMember(0)
}}>
<AlternateEmailIcon sx={{
color: mentionList?.length > 0 && (!lastMentionTimestamp || lastMentionTimestamp < mentionList[0]?.timestamp) ? 'var(--unread)' : 'white'
}} />
</ButtonBase>
</ContextMenuMentions>
<ButtonBase onClick={() => {
setMode("default")
setSearchValue('')
setSelectedMember(0)
openQManager()
}}>
<InsertLinkIcon sx={{
color: 'white'
}} />
</ButtonBase>
</Box>
</Box>
);
};

View File

@ -108,7 +108,7 @@ export const MessageDisplay = ({ htmlContent, isReply }) => {
}; };
const embedLink = htmlContent.match(/qortal:\/\/use-embed\/[^\s<>]+/); const embedLink = htmlContent?.match(/qortal:\/\/use-embed\/[^\s<>]+/);
let embedData = null; let embedData = null;

View File

@ -17,6 +17,7 @@ import { Spacer } from "../../common/Spacer";
import { ReactionPicker } from "../ReactionPicker"; import { ReactionPicker } from "../ReactionPicker";
import KeyOffIcon from '@mui/icons-material/KeyOff'; import KeyOffIcon from '@mui/icons-material/KeyOff';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import Mention from "@tiptap/extension-mention";
export const MessageItem = ({ export const MessageItem = ({
message, message,
@ -33,7 +34,8 @@ export const MessageItem = ({
reactions, reactions,
isUpdating, isUpdating,
lastSignature, lastSignature,
onEdit onEdit,
isPrivate
}) => { }) => {
const [anchorEl, setAnchorEl] = useState(null); const [anchorEl, setAnchorEl] = useState(null);
const [selectedReaction, setSelectedReaction] = useState(null); const [selectedReaction, setSelectedReaction] = useState(null);
@ -133,7 +135,7 @@ export const MessageItem = ({
gap: '10px', gap: '10px',
alignItems: 'center' alignItems: 'center'
}}> }}>
{message?.sender === myAddress && !message?.isNotEncrypted && ( {message?.sender === myAddress && (!message?.isNotEncrypted || isPrivate === false) && (
<ButtonBase <ButtonBase
onClick={() => { onClick={() => {
onEdit(message); onEdit(message);
@ -202,6 +204,7 @@ export const MessageItem = ({
StarterKit, StarterKit,
Underline, Underline,
Highlight, Highlight,
Mention
])} ])}
/> />
)} )}
@ -220,6 +223,7 @@ export const MessageItem = ({
StarterKit, StarterKit,
Underline, Underline,
Highlight, Highlight,
Mention
])} ])}
/> />
)} )}
@ -336,7 +340,7 @@ export const MessageItem = ({
alignItems: 'center', alignItems: 'center',
gap: '15px' gap: '15px'
}}> }}>
{message?.isNotEncrypted && ( {message?.isNotEncrypted && isPrivate && (
<KeyOffIcon sx={{ <KeyOffIcon sx={{
color: 'white', color: 'white',
marginLeft: '10px' marginLeft: '10px'
@ -451,6 +455,7 @@ export const ReplyPreview = ({message, isEdit})=> {
StarterKit, StarterKit,
Underline, Underline,
Highlight, Highlight,
Mention
])} ])}
/> />
)} )}

View File

@ -126,7 +126,7 @@
} }
.tiptap .mention { .tiptap [data-type="mention"] {
box-decoration-break: clone; box-decoration-break: clone;
color: lightblue; color: lightblue;
padding: 0.1rem 0.3rem; padding: 0.1rem 0.3rem;

View File

@ -34,7 +34,8 @@ import RefreshIcon from "@mui/icons-material/Refresh";
import AnnouncementsIcon from "@mui/icons-material/Notifications"; import AnnouncementsIcon from "@mui/icons-material/Notifications";
import GroupIcon from "@mui/icons-material/Group"; import GroupIcon from "@mui/icons-material/Group";
import PersonIcon from "@mui/icons-material/Person"; import PersonIcon from "@mui/icons-material/Person";
import LockIcon from '@mui/icons-material/Lock';
import NoEncryptionGmailerrorredIcon from '@mui/icons-material/NoEncryptionGmailerrorred';
import { import {
AuthenticatedContainerInnerRight, AuthenticatedContainerInnerRight,
CustomButton, CustomButton,
@ -119,6 +120,19 @@ import { sortArrayByTimestampAndGroupName } from "../../utils/time";
// } // }
// }); // });
function areKeysEqual(array1, array2) {
// If lengths differ, the arrays cannot be equal
if (array1?.length !== array2?.length) {
return false;
}
// Sort both arrays and compare their elements
const sortedArray1 = [...array1].sort();
const sortedArray2 = [...array2].sort();
return sortedArray1.every((key, index) => key === sortedArray2[index]);
}
export const getPublishesFromAdmins = async (admins: string[], groupId) => { export const getPublishesFromAdmins = async (admins: string[], groupId) => {
// const validApi = await findUsableApi(); // const validApi = await findUsableApi();
@ -476,6 +490,16 @@ export const Group = ({
const [appsMode, setAppsMode] = useState('home') const [appsMode, setAppsMode] = useState('home')
const [isOpenSideViewDirects, setIsOpenSideViewDirects] = useState(false) const [isOpenSideViewDirects, setIsOpenSideViewDirects] = useState(false)
const [isOpenSideViewGroups, setIsOpenSideViewGroups] = useState(false) const [isOpenSideViewGroups, setIsOpenSideViewGroups] = useState(false)
const [groupsProperties, setGroupsProperties] = useState({})
const isPrivate = useMemo(()=> {
if(!selectedGroup?.groupId || !groupsProperties[selectedGroup?.groupId]) return null
if(groupsProperties[selectedGroup?.groupId]?.isOpen === true) return false
if(groupsProperties[selectedGroup?.groupId]?.isOpen === false) return true
return null
}, [selectedGroup])
const setSelectedGroupId = useSetRecoilState(selectedGroupIdAtom) const setSelectedGroupId = useSetRecoilState(selectedGroupIdAtom)
const toggleSideViewDirects = ()=> { const toggleSideViewDirects = ()=> {
if(isOpenSideViewGroups){ if(isOpenSideViewGroups){
@ -682,9 +706,8 @@ export const Group = ({
if ( if (
group?.data && group?.data &&
isExtMsg(group?.data) &&
group?.sender !== myAddress && group?.sender !== myAddress &&
group?.timestamp && (!isUpdateMsg(group?.data) || groupChatTimestamps[group?.groupId]) && group?.timestamp && groupChatTimestamps[group?.groupId] &&
((!timestampEnterData[group?.groupId] && ((!timestampEnterData[group?.groupId] &&
Date.now() - group?.timestamp < timeDifferenceForNotificationChats) || Date.now() - group?.timestamp < timeDifferenceForNotificationChats) ||
timestampEnterData[group?.groupId] < group?.timestamp) timestampEnterData[group?.groupId] < group?.timestamp)
@ -844,12 +867,19 @@ export const Group = ({
useEffect(() => { useEffect(() => {
if (selectedGroup) { if (selectedGroup && isPrivate !== null) {
if(isPrivate){
setTriedToFetchSecretKey(false); setTriedToFetchSecretKey(false);
getSecretKey(true); getSecretKey(true);
}
getGroupOwner(selectedGroup?.groupId); getGroupOwner(selectedGroup?.groupId);
} }
}, [selectedGroup]); if(isPrivate === false){
setTriedToFetchSecretKey(true);
}
}, [selectedGroup, isPrivate]);
@ -880,9 +910,8 @@ export const Group = ({
const groupData = {} const groupData = {}
const getGroupData = groups.map(async(group)=> { const getGroupData = groups.map(async(group)=> {
const isUpdate = isUpdateMsg(group?.data)
if(!group.groupId || !group?.timestamp) return null if(!group.groupId || !group?.timestamp) return null
if(isUpdate && (!groupData[group.groupId] || groupData[group.groupId] < group.timestamp)){ if((!groupData[group.groupId] || groupData[group.groupId] < group.timestamp)){
const hasMoreRecentMsg = await getCountNewMesg(group.groupId, timestampEnterDataRef.current[group?.groupId] || Date.now() - 24 * 60 * 60 * 1000) const hasMoreRecentMsg = await getCountNewMesg(group.groupId, timestampEnterDataRef.current[group?.groupId] || Date.now() - 24 * 60 * 60 * 1000)
if(hasMoreRecentMsg){ if(hasMoreRecentMsg){
groupData[group.groupId] = hasMoreRecentMsg groupData[group.groupId] = hasMoreRecentMsg
@ -899,6 +928,31 @@ export const Group = ({
} }
} }
const getGroupsProperties = useCallback(async(address)=> {
try {
const url = `${getBaseApiReact()}/groups/member/${address}`;
const response = await fetch(url);
if(!response.ok) throw new Error('Cannot get group properties')
let data = await response.json();
const transformToObject = data.reduce((result, item) => {
result[item.groupId] = item
return result;
}, {});
setGroupsProperties(transformToObject)
} catch (error) {
// error
}
}, [])
useEffect(()=> {
if(!myAddress) return
if(areKeysEqual(groups?.map((grp)=> grp?.groupId), Object.keys(groupsProperties))){
} else {
getGroupsProperties(myAddress)
}
}, [groups, myAddress])
useEffect(() => { useEffect(() => {
@ -1089,9 +1143,9 @@ export const Group = ({
.filter((group) => group?.sender !== myAddress) .filter((group) => group?.sender !== myAddress)
.find((gr) => gr?.groupId === selectedGroup?.groupId); .find((gr) => gr?.groupId === selectedGroup?.groupId);
if (!findGroup) return false; if (!findGroup) return false;
if (!findGroup?.data || !isExtMsg(findGroup?.data)) return false; if (!findGroup?.data) return false;
return ( return (
findGroup?.timestamp && (!isUpdateMsg(findGroup?.data) || groupChatTimestamps[findGroup?.groupId]) && findGroup?.timestamp && groupChatTimestamps[findGroup?.groupId] &&
((!timestampEnterData[selectedGroup?.groupId] && ((!timestampEnterData[selectedGroup?.groupId] &&
Date.now() - findGroup?.timestamp < Date.now() - findGroup?.timestamp <
timeDifferenceForNotificationChats) || timeDifferenceForNotificationChats) ||
@ -1930,16 +1984,37 @@ export const Group = ({
}} }}
> >
<ListItemAvatar> <ListItemAvatar>
<Avatar {groupsProperties[group?.groupId]?.isOpen === false ? (
sx={{ <Box sx={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: "#232428", background: "#232428",
color: "white", display: 'flex',
}} alignItems: 'center',
alt={group?.groupName} justifyContent: 'center'
// src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${groupOwner?.name}/qortal_group_avatar_${group.groupId}?async=true`} }}>
> <LockIcon sx={{
{group.groupName?.charAt(0)} color: 'var(--green)'
</Avatar> }} />
</Box>
): (
<Box sx={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: "#232428",
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<NoEncryptionGmailerrorredIcon sx={{
color: 'var(--unread)'
}} />
</Box>
)}
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={group.groupName} primary={group.groupName}
@ -1975,7 +2050,7 @@ export const Group = ({
/> />
)} )}
{group?.data && {group?.data &&
isExtMsg(group?.data) && (!isUpdateMsg(group?.data) || groupChatTimestamps[group?.groupId]) && groupChatTimestamps[group?.groupId] &&
group?.sender !== myAddress && group?.sender !== myAddress &&
group?.timestamp && group?.timestamp &&
((!timestampEnterData[group?.groupId] && ((!timestampEnterData[group?.groupId] &&
@ -2054,6 +2129,7 @@ export const Group = ({
{isMobile && ( {isMobile && (
<Header <Header
isPrivate={isPrivate}
setMobileViewModeKeepOpen={setMobileViewModeKeepOpen} setMobileViewModeKeepOpen={setMobileViewModeKeepOpen}
isThin={ isThin={
mobileViewMode === "groups" || mobileViewMode === "groups" ||
@ -2310,6 +2386,7 @@ export const Group = ({
> >
{triedToFetchSecretKey && ( {triedToFetchSecretKey && (
<ChatGroup <ChatGroup
isPrivate={isPrivate}
myAddress={myAddress} myAddress={myAddress}
selectedGroup={selectedGroup?.groupId} selectedGroup={selectedGroup?.groupId}
getSecretKey={getSecretKey} getSecretKey={getSecretKey}
@ -2318,7 +2395,7 @@ export const Group = ({
handleNewEncryptionNotification={ handleNewEncryptionNotification={
setNewEncryptionNotification setNewEncryptionNotification
} }
hide={groupSection !== "chat" || !secretKey || selectedDirect || newChat} hide={groupSection !== "chat" || (!secretKey && isPrivate) || selectedDirect || newChat}
handleSecretKeyCreationInProgress={ handleSecretKeyCreationInProgress={
handleSecretKeyCreationInProgress handleSecretKeyCreationInProgress
@ -2330,7 +2407,7 @@ export const Group = ({
/> />
)} )}
{firstSecretKeyInCreation && {isPrivate && firstSecretKeyInCreation &&
triedToFetchSecretKey && triedToFetchSecretKey &&
!secretKeyPublishDate && ( !secretKeyPublishDate && (
<div <div
@ -2351,7 +2428,7 @@ export const Group = ({
</Typography> </Typography>
</div> </div>
)} )}
{!admins.includes(myAddress) && {isPrivate && !admins.includes(myAddress) &&
!secretKey && !secretKey &&
triedToFetchSecretKey ? ( triedToFetchSecretKey ? (
<> <>
@ -2404,7 +2481,7 @@ export const Group = ({
) : null} ) : null}
</> </>
) : admins.includes(myAddress) && ) : admins.includes(myAddress) &&
!secretKey && (!secretKey && isPrivate) &&
triedToFetchSecretKey ? null : !triedToFetchSecretKey ? null : ( triedToFetchSecretKey ? null : !triedToFetchSecretKey ? null : (
<> <>
<GroupAnnouncements <GroupAnnouncements
@ -2445,7 +2522,7 @@ export const Group = ({
zIndex: 100, zIndex: 100,
}} }}
> >
{admins.includes(myAddress) && {isPrivate && admins.includes(myAddress) &&
shouldReEncrypt && shouldReEncrypt &&
triedToFetchSecretKey && triedToFetchSecretKey &&
!firstSecretKeyInCreation && !firstSecretKeyInCreation &&

View File

@ -143,7 +143,7 @@ export const encryptDataGroup = ({ data64, publicKeys, privateKey, userPublicKey
} }
} }
export const encryptSingle = async ({ data64, secretKeyObject, typeNumber = 1 }: any) => { export const encryptSingle = async ({ data64, secretKeyObject, typeNumber = 2 }: any) => {
// Find the highest key in the secretKeyObject // Find the highest key in the secretKeyObject
const highestKey = Math.max(...Object.keys(secretKeyObject).filter(item => !isNaN(+item)).map(Number)); const highestKey = Math.max(...Object.keys(secretKeyObject).filter(item => !isNaN(+item)).map(Number));
const highestKeyObject = secretKeyObject[highestKey]; const highestKeyObject = secretKeyObject[highestKey];
@ -186,13 +186,29 @@ export const encryptSingle = async ({ data64, secretKeyObject, typeNumber = 1 }:
// Concatenate the highest key, type number, nonce, and encrypted data (new format) // Concatenate the highest key, type number, nonce, and encrypted data (new format)
const highestKeyStr = highestKey.toString().padStart(10, '0'); // Fixed length of 10 digits const highestKeyStr = highestKey.toString().padStart(10, '0'); // Fixed length of 10 digits
finalEncryptedData = btoa(highestKeyStr + typeNumberStr + nonceBase64 + encryptedDataBase64);
const highestKeyBytes = new TextEncoder().encode(highestKeyStr.padStart(10, '0'));
const typeNumberBytes = new TextEncoder().encode(typeNumberStr.padStart(3, '0'));
// Step 3: Concatenate all binary
const combinedBinary = new Uint8Array(
highestKeyBytes.length + typeNumberBytes.length + nonce.length + encryptedData.length
);
// finalEncryptedData = btoa(highestKeyStr) + btoa(typeNumberStr) + nonceBase64 + encryptedDataBase64;
combinedBinary.set(highestKeyBytes, 0);
combinedBinary.set(typeNumberBytes, highestKeyBytes.length);
combinedBinary.set(nonce, highestKeyBytes.length + typeNumberBytes.length);
combinedBinary.set(encryptedData, highestKeyBytes.length + typeNumberBytes.length + nonce.length);
// Step 4: Base64 encode once
finalEncryptedData = uint8ArrayToBase64(combinedBinary);
} }
return finalEncryptedData; return finalEncryptedData;
}; };
export const decodeBase64ForUIChatMessages = (messages)=> { export const decodeBase64ForUIChatMessages = (messages)=> {
let msgs = [] let msgs = []
@ -200,12 +216,12 @@ export const decodeBase64ForUIChatMessages = (messages)=> {
try { try {
const decoded = atob(msg?.data); const decoded = atob(msg?.data);
const parseDecoded =JSON.parse(decodeURIComponent(escape(decoded))) const parseDecoded =JSON.parse(decodeURIComponent(escape(decoded)))
if(parseDecoded?.messageText){
msgs.push({ msgs.push({
...msg, ...msg,
...parseDecoded ...parseDecoded
}) })
}
} catch (error) { } catch (error) {
} }
@ -247,6 +263,28 @@ export const decodeBase64ForUIChatMessages = (messages)=> {
encryptedDataBase64 = decodeForNumber.slice(10); // The remaining part is the encrypted data encryptedDataBase64 = decodeForNumber.slice(10); // The remaining part is the encrypted data
} else { } else {
if (hasTypeNumber) { if (hasTypeNumber) {
// const typeNumberStr = new TextDecoder().decode(typeNumberBytes);
if(decodeForNumber.slice(10, 13) !== '001'){
const decodedBinary = base64ToUint8Array(decodedData);
const highestKeyBytes = decodedBinary.slice(0, 10); // if ASCII digits only
const highestKeyStr = new TextDecoder().decode(highestKeyBytes);
const nonce = decodedBinary.slice(13, 13 + 24);
const encryptedData = decodedBinary.slice(13 + 24);
const highestKey = parseInt(highestKeyStr, 10);
const messageKey = base64ToUint8Array(secretKeyObject[+highestKey].messageKey);
const decryptedBytes = nacl.secretbox.open(encryptedData, nonce, messageKey);
// Check if decryption was successful
if (!decryptedBytes) {
throw new Error("Decryption failed");
}
// Convert the decrypted Uint8Array back to a Base64 string
return uint8ArrayToBase64(decryptedBytes);
}
// New format: Extract type number and nonce // New format: Extract type number and nonce
typeNumberStr = possibleTypeNumberStr; // Extract type number typeNumberStr = possibleTypeNumberStr; // Extract type number
nonceBase64 = decodeForNumber.slice(13, 45); // Extract nonce (next 32 characters after type number) nonceBase64 = decodeForNumber.slice(13, 45); // Extract nonce (next 32 characters after type number)

View File

@ -7,6 +7,9 @@ import { mimeToExtensionMap } from '../memeTypes';
import PhraseWallet from './phrase-wallet'; import PhraseWallet from './phrase-wallet';
import * as WORDLISTS from './wordlists'; import * as WORDLISTS from './wordlists';
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem'; import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
import ShortUniqueId from "short-unique-id";
const uid = new ShortUniqueId({ length: 8 });
export function generateRandomSentence(template = 'adverb verb noun adjective noun adverb verb noun adjective noun adjective verbed adjective noun', maxWordLength = 0, capitalize = true) { export function generateRandomSentence(template = 'adverb verb noun adjective noun adverb verb noun adjective noun adjective verbed adjective noun', maxWordLength = 0, capitalize = true) {
const partsOfSpeechMap = { const partsOfSpeechMap = {
'noun': 'nouns', 'noun': 'nouns',
@ -88,7 +91,7 @@ export const createAccount = async(generatedSeedPhrase)=> {
export const saveFileToDisk = async (data: any, qortAddress: string) => { export const saveFileToDisk = async (data: any, qortAddress: string) => {
const dataString = JSON.stringify(data); const dataString = JSON.stringify(data);
const fileName = `qortal_backup_${qortAddress}.json`; const fileName = `qortal_backup_${qortAddress}_${uid.rnd()}.json`;
// Write the file to the Filesystem // Write the file to the Filesystem
await Filesystem.writeFile({ await Filesystem.writeFile({
@ -102,7 +105,7 @@ export const createAccount = async(generatedSeedPhrase)=> {
export const saveSeedPhraseToDisk = async (data) => { export const saveSeedPhraseToDisk = async (data) => {
const fileName = "qortal_seedphrase.txt" const fileName = `qortal_seedphrase_${uid.rnd()}.txt`
await Filesystem.writeFile({ await Filesystem.writeFile({
path: fileName, path: fileName,