version 2 - beta

This commit is contained in:
2024-09-09 20:36:39 +03:00
parent c2fcfaeaed
commit f75efc8cf6
99 changed files with 20541 additions and 757 deletions

View File

@@ -0,0 +1,344 @@
import React, { useMemo, useRef, useState } from "react";
import TipTap from "./TipTap";
import { AuthenticatedContainerInnerTop, CustomButton } from "../../App-styles";
import { Box, CircularProgress } from "@mui/material";
import { objectToBase64 } from "../../qdn/encryption/group-encryption";
import ShortUniqueId from "short-unique-id";
import { LoadingSnackbar } from "../Snackbar/LoadingSnackbar";
import { getBaseApi, getFee } from "../../background";
import { decryptPublishes, getTempPublish, saveTempPublish } from "./GroupAnnouncements";
import { AnnouncementList } from "./AnnouncementList";
import { Spacer } from "../../common/Spacer";
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { getBaseApiReact } from "../../App";
const tempKey = 'accouncement-comment'
const uid = new ShortUniqueId({ length: 8 });
export const AnnouncementDiscussion = ({
getSecretKey,
encryptChatMessage,
selectedAnnouncement,
secretKey,
setSelectedAnnouncement,
show,
myName
}) => {
const [isSending, setIsSending] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [comments, setComments] = useState([])
const [tempPublishedList, setTempPublishedList] = useState([])
const firstMountRef = useRef(false)
const [data, setData] = useState({})
const editorRef = useRef(null);
const setEditorRef = (editorInstance) => {
editorRef.current = editorInstance;
};
const clearEditorContent = () => {
if (editorRef.current) {
editorRef.current.chain().focus().clearContent().run();
}
};
const getData = async ({ identifier, name }) => {
try {
const res = await fetch(
`${getBaseApiReact()}/arbitrary/DOCUMENT/${name}/${identifier}?encoding=base64`
);
const data = await res.text();
const response = await decryptPublishes([{ data }], secretKey);
const messageData = response[0];
setData((prev) => {
return {
...prev,
[`${identifier}-${name}`]: messageData,
};
});
} catch (error) {}
};
const publishAnc = async ({ encryptedData, identifier }: any) => {
try {
if (!selectedAnnouncement) return;
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "publishGroupEncryptedResource",
payload: {
encryptedData,
identifier,
},
},
(response) => {
if (!response?.error) {
res(response);
}
rej(response.error);
}
);
});
} catch (error) {}
};
const setTempData = async ()=> {
try {
const getTempAnnouncements = await getTempPublish()
if(getTempAnnouncements[tempKey]){
let tempData = []
Object.keys(getTempAnnouncements[tempKey] || {}).map((key)=> {
const value = getTempAnnouncements[tempKey][key]
if(value.data?.announcementId === selectedAnnouncement.identifier){
tempData.push(value.data)
}
})
setTempPublishedList(tempData)
}
} catch (error) {
}
}
const publishComment = async () => {
try {
const fee = await getFee('ARBITRARY')
await show({
message: "Would you like to perform a ARBITRARY transaction?" ,
publishFee: fee.fee + ' QORT'
})
if (isSending) return;
if (editorRef.current) {
const htmlContent = editorRef.current.getHTML();
if (!htmlContent?.trim() || htmlContent?.trim() === "<p></p>") return;
setIsSending(true);
const message = {
version: 1,
extra: {},
message: htmlContent,
};
const secretKeyObject = await getSecretKey();
const message64: any = await objectToBase64(message);
const encryptSingle = await encryptChatMessage(
message64,
secretKeyObject
);
const randomUid = uid.rnd();
const identifier = `cm-${selectedAnnouncement.identifier}-${randomUid}`;
const res = await publishAnc({
encryptedData: encryptSingle,
identifier
});
const dataToSaveToStorage = {
name: myName,
identifier,
service: 'DOCUMENT',
tempData: message,
created: Date.now(),
announcementId: selectedAnnouncement.identifier
}
await saveTempPublish({data: dataToSaveToStorage, key: tempKey})
setTempData()
clearEditorContent();
}
// send chat message
} catch (error) {
console.error(error);
} finally {
setIsSending(false);
}
};
const getComments = React.useCallback(
async (selectedAnnouncement) => {
try {
setIsLoading(true);
const offset = 0;
// dispatch(setIsLoadingGlobal(true))
const identifier = `cm-${selectedAnnouncement.identifier}`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=20&includemetadata=false&offset=${offset}&reverse=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
setTempData()
setComments(responseData);
setIsLoading(false);
for (const data of responseData) {
getData({ name: data.name, identifier: data.identifier });
}
} catch (error) {
} finally {
setIsLoading(false);
// dispatch(setIsLoadingGlobal(false))
}
},
[secretKey]
);
const loadMore = async()=> {
try {
setIsLoading(true);
const offset = comments.length
const identifier = `cm-${selectedAnnouncement.identifier}`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=20&includemetadata=false&offset=${offset}&reverse=true&prefix=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
setComments((prev)=> [...prev, ...responseData]);
setIsLoading(false);
for (const data of responseData) {
getData({ name: data.name, identifier: data.identifier });
}
} catch (error) {
}
}
const combinedListTempAndReal = useMemo(() => {
// Combine the two lists
const combined = [...tempPublishedList, ...comments];
// Remove duplicates based on the "identifier"
const uniqueItems = new Map();
combined.forEach(item => {
uniqueItems.set(item.identifier, item); // This will overwrite duplicates, keeping the last occurrence
});
// Convert the map back to an array and sort by "created" timestamp in descending order
const sortedList = Array.from(uniqueItems.values()).sort((a, b) => b.created - a.created);
return sortedList;
}, [tempPublishedList, comments]);
React.useEffect(() => {
if (selectedAnnouncement && secretKey && !firstMountRef.current) {
getComments(selectedAnnouncement);
firstMountRef.current = true
}
}, [selectedAnnouncement, secretKey]);
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<div style={{
position: "relative",
width: "100%",
display: "flex",
flexDirection: "column",
flexShrink: 0,
}}>
<AuthenticatedContainerInnerTop>
<ArrowBackIcon onClick={()=> setSelectedAnnouncement(null)} sx={{
cursor: 'pointer'
}} />
</AuthenticatedContainerInnerTop>
<Spacer height="20px" />
</div>
<AnnouncementList
announcementData={data}
initialMessages={combinedListTempAndReal}
setSelectedAnnouncement={()=> {}}
disableComment
showLoadMore={comments.length > 0 && comments.length % 20 === 0}
loadMore={loadMore}
/>
<div
style={{
// position: 'fixed',
// bottom: '0px',
backgroundColor: "#232428",
minHeight: "150px",
maxHeight: "400px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
width: "100%",
boxSizing: "border-box",
padding: "20px",
flexShrink:0,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
// height: '100%',
overflow: "auto",
}}
>
<TipTap
setEditorRef={setEditorRef}
onEnter={publishComment}
disableEnter
/>
</div>
<CustomButton
onClick={() => {
if (isSending) return;
publishComment();
}}
style={{
marginTop: "auto",
alignSelf: "center",
cursor: isSending ? "default" : "pointer",
background: isSending && "rgba(0, 0, 0, 0.8)",
flexShrink: 0,
}}
>
{isSending && (
<CircularProgress
size={18}
sx={{
position: "absolute",
top: "50%",
left: "50%",
marginTop: "-12px",
marginLeft: "-12px",
color: "white",
}}
/>
)}
{` Publish Comment`}
</CustomButton>
</div>
<LoadingSnackbar
open={isLoading}
info={{
message: "Loading comments... please wait.",
}}
/>
</div>
);
};

View File

@@ -0,0 +1,167 @@
import { Message } from "@chatscope/chat-ui-kit-react";
import React, { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import { MessageDisplay } from "./MessageDisplay";
import { Avatar, Box, Typography } from "@mui/material";
import { formatTimestamp } from "../../utils/time";
import ChatBubbleIcon from '@mui/icons-material/ChatBubble';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import { getBaseApi } from "../../background";
import { requestQueueCommentCount } from "./GroupAnnouncements";
import { CustomLoader } from "../../common/CustomLoader";
import { getBaseApiReact } from "../../App";
export const AnnouncementItem = ({ message, messageData, setSelectedAnnouncement, disableComment }) => {
const [commentLength, setCommentLength] = useState(0)
const getNumberOfComments = React.useCallback(
async () => {
try {
const offset = 0;
// dispatch(setIsLoadingGlobal(true))
const identifier = `cm-${message.identifier}`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=0&includemetadata=false&offset=${offset}&reverse=true`;
const response = await requestQueueCommentCount.enqueue(() => {
return fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
})
const responseData = await response.json();
setCommentLength(responseData?.length);
} catch (error) {
} finally {
// dispatch(setIsLoadingGlobal(false))
}
},
[]
);
useEffect(()=> {
if(disableComment) return
getNumberOfComments()
}, [])
return (
<div
style={{
padding: "10px",
backgroundColor: "#232428",
borderRadius: "7px",
width: "95%",
display: "flex",
gap: '7px',
flexDirection: 'column'
}}
>
<Box sx={{
display: "flex",
gap: '7px',
width: '100%'
}}>
<Avatar
sx={{
backgroundColor: '#27282c',
color: 'white'
}}
alt={message?.name}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${message?.name}/qortal_avatar?async=true`}
>
{message?.name?.charAt(0)}
</Avatar>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "7px",
width: '100%'
}}
>
<Typography
sx={{
fontWight: 600,
fontFamily: "Inter",
color: "cadetBlue",
}}
>
{message?.name}
</Typography>
{!messageData?.decryptedData && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<CustomLoader />
</Box>
)}
{messageData?.decryptedData?.message && (
<>
{messageData?.type === "notification" ? (
<MessageDisplay htmlContent={messageData?.decryptedData?.message} />
) : (
<MessageDisplay htmlContent={messageData?.decryptedData?.message} />
)}
</>
)}
<Box sx={{
display: 'flex',
justifyContent: 'flex-end',
width: '100%'
}}>
<Typography sx={{
fontSize: '14px',
color: 'gray',
fontFamily: 'Inter'
}}>{formatTimestamp(message.created)}</Typography>
</Box>
</Box>
</Box>
{!disableComment && (
<Box sx={{
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'space-between',
padding: '20px',
cursor: 'pointer',
opacity: 0.4,
borderTop: '1px solid white',
}} onClick={()=> setSelectedAnnouncement(message)}>
<Box sx={{
display: 'flex',
width: '100%',
gap: '25px',
alignItems: 'center',
}}>
<ChatBubbleIcon sx={{
fontSize: '20px'
}} />
{commentLength ? (
<Typography sx={{
fontSize: '14px'
}}>{`${commentLength > 1 ? `${commentLength} comments` : `${commentLength} comment`}`}</Typography>
) : (
<Typography sx={{
fontSize: '14px'
}}>Leave comment</Typography>
)}
</Box>
<ArrowForwardIosIcon sx={{
fontSize: '20px'
}} />
</Box>
)}
</div>
);
};

View File

@@ -0,0 +1,96 @@
import React, { useCallback, useState, useEffect, useRef } from "react";
import {
List,
AutoSizer,
CellMeasurerCache,
CellMeasurer,
} from "react-virtualized";
import { AnnouncementItem } from "./AnnouncementItem";
import { Box } from "@mui/material";
import { CustomButton } from "../../App-styles";
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const AnnouncementList = ({
initialMessages,
announcementData,
setSelectedAnnouncement,
disableComment,
showLoadMore,
loadMore
}) => {
const listRef = useRef();
const [messages, setMessages] = useState(initialMessages);
useEffect(() => {
cache.clearAll();
}, []);
useEffect(() => {
setMessages(initialMessages);
}, [initialMessages]);
return (
<div
style={{
position: "relative",
flexGrow: 1,
width: "100%",
display: "flex",
flexDirection: "column",
flexShrink: 1,
overflow: 'auto'
}}
>
{messages.map((message) => {
const messageData = message?.tempData ? {
decryptedData: message?.tempData
} : announcementData[`${message.identifier}-${message.name}`];
return (
<div
style={{
marginBottom: "10px",
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<AnnouncementItem disableComment={disableComment} setSelectedAnnouncement={setSelectedAnnouncement} message={message} messageData={messageData} />
</div>
);
})}
{/* <AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={messages.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer> */}
<Box sx={{
width: '100%',
marginTop: '25px',
display: 'flex',
justifyContent: 'center'
}}>
{showLoadMore && (
<CustomButton onClick={loadMore}>Load older announcements</CustomButton>
)}
</Box>
</div>
);
};

View File

@@ -0,0 +1,56 @@
import React, { useState } from "react";
import InfiniteScroll from "react-infinite-scroller";
import {
MainContainer,
ChatContainer,
MessageList,
Message,
MessageInput,
Avatar
} from "@chatscope/chat-ui-kit-react";
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
export const ChatContainerComp = ({messages}) => {
// const [messages, setMessages] = useState([
// { id: 1, text: "Hello! How are you?", sender: "Joe"},
// { id: 2, text: "I'm good, thank you!", sender: "Me" }
// ]);
// const loadMoreMessages = () => {
// // Simulate loading more messages (you could fetch these from an API)
// const moreMessages = [
// { id: 3, text: "What about you?", sender: "Joe", direction: "incoming" },
// { id: 4, text: "I'm great, thanks!", sender: "Me", direction: "outgoing" }
// ];
// setMessages((prevMessages) => [...moreMessages, ...prevMessages]);
// };
return (
<div style={{ height: "500px", width: "300px" }}>
<MainContainer>
<ChatContainer>
<MessageList>
{messages.map((msg) => (
<Message
key={msg.id}
model={{
message: msg.text,
sentTime: "just now",
sender: msg.senderName,
direction: 'incoming',
position: "single"
}}
>
{msg.direction === "incoming" && <Avatar name={msg.senderName} />}
</Message>
))}
</MessageList>
<MessageInput placeholder="Type a message..." />
</ChatContainer>
</MainContainer>
</div>
);
};

View File

@@ -0,0 +1,305 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { objectToBase64 } from '../../qdn/encryption/group-encryption'
import { ChatList } from './ChatList'
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import Tiptap from './TipTap'
import { CustomButton } from '../../App-styles'
import CircularProgress from '@mui/material/CircularProgress';
import { Input } from '@mui/material';
import { LoadingSnackbar } from '../Snackbar/LoadingSnackbar';
import { getNameInfo } from '../Group/Group';
import { Spacer } from '../../common/Spacer';
import { CustomizedSnackbars } from '../Snackbar/Snackbar';
import { getBaseApiReactSocket } from '../../App';
export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDirect, setNewChat, getTimestampEnterChat, myName}) => {
const [messages, setMessages] = useState([])
const [isSending, setIsSending] = useState(false)
const [directToValue, setDirectToValue] = useState('')
const hasInitialized = useRef(false)
const [isLoading, setIsLoading] = useState(false)
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const hasInitializedWebsocket = useRef(false)
const editorRef = useRef(null);
const setEditorRef = (editorInstance) => {
editorRef.current = editorInstance;
};
const decryptMessages = (encryptedMessages: any[])=> {
try {
return new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "decryptDirect", payload: {
data: encryptedMessages,
involvingAddress: selectedDirect?.address
}}, (response) => {
if (!response?.error) {
res(response)
if(hasInitialized.current){
const formatted = response.map((item: any)=> {
return {
...item,
id: item.signature,
text: item.message,
unread: true
}
} )
setMessages((prev)=> [...prev, ...formatted])
} else {
const formatted = response.map((item: any)=> {
return {
...item,
id: item.signature,
text: item.message,
unread: false
}
} )
setMessages(formatted)
hasInitialized.current = true
}
}
rej(response.error)
});
})
} catch (error) {
}
}
const initWebsocketMessageGroup = () => {
let timeoutId
let groupSocketTimeout
let socketTimeout: any
let socketLink = `${getBaseApiReactSocket()}/websockets/chat/messages?involving=${selectedDirect?.address}&involving=${myAddress}&encoding=BASE64&limit=100`
const socket = new WebSocket(socketLink)
const pingGroupSocket = () => {
socket.send('ping')
timeoutId = setTimeout(() => {
socket.close()
clearTimeout(groupSocketTimeout)
}, 5000) // Close the WebSocket connection if no pong message is received within 5 seconds.
}
socket.onopen = () => {
setTimeout(pingGroupSocket, 50)
}
socket.onmessage = (e) => {
try {
if (e.data === 'pong') {
clearTimeout(timeoutId)
groupSocketTimeout = setTimeout(pingGroupSocket, 45000)
return
} else {
decryptMessages(JSON.parse(e.data))
setIsLoading(false)
}
} catch (error) {
}
}
socket.onclose = () => {
console.log('closed')
clearTimeout(socketTimeout)
setTimeout(() => initWebsocketMessageGroup(), 50)
}
socket.onerror = (e) => {
clearTimeout(groupSocketTimeout)
socket.close()
}
}
useEffect(()=> {
if(hasInitializedWebsocket.current) return
setIsLoading(true)
initWebsocketMessageGroup()
hasInitializedWebsocket.current = true
}, [])
const sendChatDirect = async ({ chatReference = undefined, messageText}: any)=> {
try {
const directTo = isNewChat ? directToValue : selectedDirect.address
if(!directTo) return
return new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "sendChatDirect", payload: {
directTo, chatReference, messageText
}}, async (response) => {
if (!response?.error) {
if(isNewChat){
let getRecipientName = null
try {
getRecipientName = await getNameInfo(response.recipient)
} catch (error) {
}
setSelectedDirect({
"address": response.recipient,
"name": getRecipientName,
"timestamp": Date.now(),
"sender": myAddress,
"senderName": myName
})
setNewChat(null)
chrome.runtime.sendMessage({
action: "addTimestampEnterChat",
payload: {
timestamp: Date.now(),
groupId: response.recipient,
},
});
setTimeout(() => {
getTimestampEnterChat()
}, 400);
}
res(response)
return
}
rej(response.error)
});
})
} catch (error) {
throw new Error(error)
}
}
const clearEditorContent = () => {
if (editorRef.current) {
editorRef.current.chain().focus().clearContent().run();
}
};
const sendMessage = async ()=> {
try {
if(isSending) return
if (editorRef.current) {
const htmlContent = editorRef.current.getHTML();
if(!htmlContent?.trim() || htmlContent?.trim() === '<p></p>') return
setIsSending(true)
const message = JSON.stringify(htmlContent)
const res = await sendChatDirect({ messageText: htmlContent})
clearEditorContent()
}
// send chat message
} catch (error) {
setInfoSnack({
type: "error",
message: error,
});
setOpenSnack(true);
console.error(error)
} finally {
setIsSending(false)
}
}
return (
<div style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
width: '100%'
}}>
{isNewChat && (
<>
<Spacer height="30px" />
<Input sx={{
fontSize: '18px'
}} placeholder='Name or address' value={directToValue} onChange={(e)=> setDirectToValue(e.target.value)} />
</>
)}
<ChatList initialMessages={messages} myAddress={myAddress}/>
<div style={{
// position: 'fixed',
// bottom: '0px',
backgroundColor: "#232428",
minHeight: '150px',
maxHeight: '400px',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
width: '100%',
boxSizing: 'border-box',
padding: '20px'
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
// height: '100%',
overflow: 'auto'
}}>
<Tiptap setEditorRef={setEditorRef} onEnter={sendMessage} />
</div>
<CustomButton
onClick={()=> {
if(isSending) return
sendMessage()
}}
style={{
marginTop: 'auto',
alignSelf: 'center',
cursor: isSending ? 'default' : 'pointer',
background: isSending && 'rgba(0, 0, 0, 0.8)',
flexShrink: 0
}}
>
{isSending && (
<CircularProgress
size={18}
sx={{
position: 'absolute',
top: '50%',
left: '50%',
marginTop: '-12px',
marginLeft: '-12px',
color: 'white'
}}
/>
)}
{` Send`}
</CustomButton>
</div>
<LoadingSnackbar open={isLoading} info={{
message: "Loading chat... please wait."
}} />
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
</div>
)
}

View File

@@ -0,0 +1,377 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CreateCommonSecret } from './CreateCommonSecret'
import { reusableGet } from '../../qdn/publish/pubish'
import { uint8ArrayToObject } from '../../backgroundFunctions/encryption'
import { base64ToUint8Array, objectToBase64 } from '../../qdn/encryption/group-encryption'
import { ChatContainerComp } from './ChatContainer'
import { ChatList } from './ChatList'
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import Tiptap from './TipTap'
import { CustomButton } from '../../App-styles'
import CircularProgress from '@mui/material/CircularProgress';
import { LoadingSnackbar } from '../Snackbar/LoadingSnackbar'
import { getBaseApiReactSocket } from '../../App'
import { CustomizedSnackbars } from '../Snackbar/Snackbar'
import { PUBLIC_NOTIFICATION_CODE_FIRST_SECRET_KEY } from '../../constants/codes'
export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, myAddress, handleNewEncryptionNotification, hide, handleSecretKeyCreationInProgress, triedToFetchSecretKey}) => {
const [messages, setMessages] = useState([])
const [isSending, setIsSending] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isMoved, setIsMoved] = useState(false);
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const hasInitialized = useRef(false)
const hasInitializedWebsocket = useRef(false)
const socketRef = useRef(null); // WebSocket reference
const timeoutIdRef = useRef(null); // Timeout ID reference
const groupSocketTimeoutRef = useRef(null); // Group Socket Timeout reference
const editorRef = useRef(null);
const setEditorRef = (editorInstance) => {
editorRef.current = editorInstance;
};
const secretKeyRef = useRef(null)
useEffect(()=> {
if(secretKey){
secretKeyRef.current = secretKey
}
}, [secretKey])
// const getEncryptedSecretKey = useCallback(()=> {
// const response = getResource()
// const decryptResponse = decryptResource()
// return
// }, [])
const checkForFirstSecretKeyNotification = (messages)=> {
messages?.forEach((message)=> {
try {
const decodeMsg = atob(message.data);
if(decodeMsg === PUBLIC_NOTIFICATION_CODE_FIRST_SECRET_KEY){
handleSecretKeyCreationInProgress()
return
}
} catch (error) {
}
})
}
const decryptMessages = (encryptedMessages: any[])=> {
try {
if(!secretKeyRef.current){
checkForFirstSecretKeyNotification(encryptedMessages)
return
}
return new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "decryptSingle", payload: {
data: encryptedMessages,
secretKeyObject: secretKey
}}, (response) => {
if (!response?.error) {
res(response)
if(hasInitialized.current){
const formatted = response.map((item: any)=> {
return {
...item,
id: item.signature,
text: item.text,
unread: true
}
} )
setMessages((prev)=> [...prev, ...formatted])
} else {
const formatted = response.map((item: any)=> {
return {
...item,
id: item.signature,
text: item.text,
unread: false
}
} )
setMessages(formatted)
hasInitialized.current = true
}
}
rej(response.error)
});
})
} catch (error) {
}
}
const forceCloseWebSocket = () => {
if (socketRef.current) {
clearTimeout(timeoutIdRef.current);
clearTimeout(groupSocketTimeoutRef.current);
socketRef.current.close(1000, 'forced');
socketRef.current = null;
}
};
const pingGroupSocket = () => {
try {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send('ping');
timeoutIdRef.current = setTimeout(() => {
if (socketRef.current) {
socketRef.current.close();
clearTimeout(groupSocketTimeoutRef.current);
}
}, 5000); // Close if no pong in 5 seconds
}
} catch (error) {
console.error('Error during ping:', error);
}
}
const initWebsocketMessageGroup = () => {
let socketLink = `${getBaseApiReactSocket()}/websockets/chat/messages?txGroupId=${selectedGroup}&encoding=BASE64&limit=100`
socketRef.current = new WebSocket(socketLink)
socketRef.current.onopen = () => {
setTimeout(pingGroupSocket, 50)
}
socketRef.current.onmessage = (e) => {
try {
if (e.data === 'pong') {
clearTimeout(timeoutIdRef.current);
groupSocketTimeoutRef.current = setTimeout(pingGroupSocket, 45000); // Ping every 45 seconds
} else {
decryptMessages(JSON.parse(e.data))
setIsLoading(false)
}
} catch (error) {
}
}
socketRef.current.onclose = () => {
clearTimeout(groupSocketTimeoutRef.current);
clearTimeout(timeoutIdRef.current);
console.warn(`WebSocket closed: ${event.reason || 'unknown reason'}`);
if (event.reason !== 'forced' && event.code !== 1000) {
setTimeout(() => initWebsocketMessageGroup(), 1000); // Retry after 10 seconds
}
}
socketRef.current.onerror = (e) => {
console.error('WebSocket error:', error);
clearTimeout(groupSocketTimeoutRef.current);
clearTimeout(timeoutIdRef.current);
if (socketRef.current) {
socketRef.current.close();
}
}
}
useEffect(()=> {
if(hasInitializedWebsocket.current) return
if(triedToFetchSecretKey && !secretKey){
forceCloseWebSocket()
setMessages([])
setIsLoading(true)
initWebsocketMessageGroup()
}
}, [triedToFetchSecretKey, secretKey])
useEffect(()=> {
if(!secretKey || hasInitializedWebsocket.current) return
forceCloseWebSocket()
setMessages([])
setIsLoading(true)
initWebsocketMessageGroup()
hasInitializedWebsocket.current = true
}, [secretKey])
useEffect(()=> {
const notifications = messages.filter((message)=> message?.text?.type === 'notification')
if(notifications.length === 0) return
const latestNotification = notifications.reduce((latest, current) => {
return current.timestamp > latest.timestamp ? current : latest;
}, notifications[0]);
handleNewEncryptionNotification(latestNotification)
}, [messages])
const encryptChatMessage = async (data: string, secretKeyObject: any)=> {
try {
return new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "encryptSingle", payload: {
data,
secretKeyObject
}}, (response) => {
if (!response?.error) {
res(response)
}
rej(response.error)
});
})
} catch (error) {
}
}
const sendChatGroup = async ({groupId, typeMessage = undefined, chatReference = undefined, messageText}: any)=> {
try {
return new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "sendChatGroup", payload: {
groupId, typeMessage, chatReference, messageText
}}, (response) => {
if (!response?.error) {
res(response)
return
}
rej(response.error)
});
})
} catch (error) {
throw new Error(error)
}
}
const clearEditorContent = () => {
if (editorRef.current) {
editorRef.current.chain().focus().clearContent().run();
}
};
const sendMessage = async ()=> {
try {
if(isSending) return
if (editorRef.current) {
const htmlContent = editorRef.current.getHTML();
if(!htmlContent?.trim() || htmlContent?.trim() === '<p></p>') return
setIsSending(true)
const message = htmlContent
const secretKeyObject = await getSecretKey()
const message64: any = await objectToBase64(message)
const encryptSingle = await encryptChatMessage(message64, secretKeyObject)
const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle})
clearEditorContent()
}
// send chat message
} catch (error) {
setInfoSnack({
type: "error",
message: error,
});
setOpenSnack(true);
console.error(error)
} finally {
setIsSending(false)
}
}
useEffect(() => {
if (hide) {
setTimeout(() => setIsMoved(true), 500); // Wait for the fade-out to complete before moving
} else {
setIsMoved(false); // Reset the position immediately when showing
}
}, [hide]);
return (
<div style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
width: '100%',
opacity: hide ? 0 : 1,
visibility: hide && 'hidden',
position: hide ? 'fixed' : 'relative',
left: hide && '-1000px',
}}>
<ChatList initialMessages={messages} myAddress={myAddress}/>
<div style={{
// position: 'fixed',
// bottom: '0px',
backgroundColor: "#232428",
minHeight: '150px',
maxHeight: '400px',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
width: '100%',
boxSizing: 'border-box',
padding: '20px'
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
// height: '100%',
overflow: 'auto'
}}>
<Tiptap setEditorRef={setEditorRef} onEnter={sendMessage} />
</div>
<CustomButton
onClick={()=> {
if(isSending) return
sendMessage()
}}
style={{
marginTop: 'auto',
alignSelf: 'center',
cursor: isSending ? 'default' : 'pointer',
background: isSending && 'rgba(0, 0, 0, 0.8)',
flexShrink: 0
}}
>
{isSending && (
<CircularProgress
size={18}
sx={{
position: 'absolute',
top: '50%',
left: '50%',
marginTop: '-12px',
marginLeft: '-12px',
color: 'white'
}}
/>
)}
{` Send`}
</CustomButton>
{/* <button onClick={sendMessage}>send</button> */}
</div>
{/* <ChatContainerComp messages={formatMessages} /> */}
<LoadingSnackbar open={isLoading} info={{
message: "Loading chat... please wait."
}} />
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
</div>
)
}

View File

@@ -0,0 +1,144 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { List, AutoSizer, CellMeasurerCache, CellMeasurer } from 'react-virtualized';
import { MessageItem } from './MessageItem';
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const ChatList = ({ initialMessages, myAddress }) => {
const hasLoadedInitialRef = useRef(false);
const listRef = useRef();
const [messages, setMessages] = useState(initialMessages);
const [showScrollButton, setShowScrollButton] = useState(false);
useEffect(()=> {
cache.clearAll();
}, [])
const handleMessageSeen = useCallback((messageId) => {
setMessages((prevMessages) =>
prevMessages.map((msg) =>
msg.id === messageId ? { ...msg, unread: false } : msg
)
);
}, []);
const handleScroll = ({ scrollTop, scrollHeight, clientHeight }) => {
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50;
const hasUnreadMessages = messages.some((msg) => msg.unread);
if (!isAtBottom && hasUnreadMessages) {
setShowScrollButton(true);
} else {
setShowScrollButton(false);
}
};
const scrollToBottom = () => {
if (listRef.current) {
listRef.current?.recomputeRowHeights();
listRef.current.scrollToRow(messages.length - 1);
setTimeout(() => {
listRef.current?.recomputeRowHeights();
listRef.current.scrollToRow(messages.length - 1);
}, 100);
setShowScrollButton(false);
}
};
const rowRenderer = ({ index, key, parent, style }) => {
const message = messages[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style}>
<div onLoad={measure} style={{
marginBottom: '10px',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}>
<MessageItem message={message} onSeen={handleMessageSeen} />
</div>
</div>
)}
</CellMeasurer>
);
};
useEffect(() => {
setMessages(initialMessages);
setTimeout(() => {
if (listRef.current) {
// Accessing scrollTop, scrollHeight, clientHeight from List's methods
const scrollTop = listRef.current.Grid._scrollingContainer.scrollTop;
const scrollHeight = listRef.current.Grid._scrollingContainer.scrollHeight;
const clientHeight = listRef.current.Grid._scrollingContainer.clientHeight;
handleScroll({ scrollTop, scrollHeight, clientHeight });
}
}, 100);
}, [initialMessages]);
useEffect(() => {
// Scroll to the bottom on initial load or when messages change
if (listRef.current && messages.length > 0 && hasLoadedInitialRef.current === false) {
scrollToBottom();
hasLoadedInitialRef.current = true;
} else if (messages.length > 0 && messages[messages.length - 1].sender === myAddress) {
scrollToBottom();
}
}, [messages, myAddress]);
return (
<div style={{ position: 'relative', flexGrow: 1, width: '100%', display: 'flex', flexDirection: 'column', flexShrink: 1 }}>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={messages.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
onScroll={handleScroll}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
{showScrollButton && (
<button
onClick={scrollToBottom}
style={{
position: 'absolute',
bottom: 20,
right: 20,
backgroundColor: '#ff5a5f',
color: 'white',
padding: '10px 20px',
borderRadius: '20px',
cursor: 'pointer',
zIndex: 10,
}}
>
Scroll to Unread Messages
</button>
)}
</div>
);
};

View File

@@ -0,0 +1,79 @@
import { Box, Button, Typography } from '@mui/material'
import React, { useContext } from 'react'
import { CustomizedSnackbars } from '../Snackbar/Snackbar';
import { LoadingButton } from '@mui/lab';
import { MyContext } from '../../App';
import { getFee } from '../../background';
export const CreateCommonSecret = ({groupId, secretKey, isOwner, myAddress, secretKeyDetails, userInfo, noSecretKey}) => {
const { show, setTxList } = useContext(MyContext);
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(false)
const createCommonSecret = async ()=> {
try {
const fee = await getFee('ARBITRARY')
await show({
message: "Would you like to perform an ARBITRARY transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoading(true)
chrome.runtime.sendMessage({ action: "encryptAndPublishSymmetricKeyGroupChat", payload: {
groupId: groupId,
previousData: secretKey
} }, (response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message: "Successfully re-encrypted secret key. It may take a couple of minutes for the changes to propagate. Refresh the group in 5mins",
});
setOpenSnack(true);
setTxList((prev)=> [{
...response,
type: 'created-common-secret',
label: `Published secret key for group ${groupId}: awaiting confirmation`,
labelDone: `Published secret key for group ${groupId}: success!`,
done: false,
groupId,
}, ...prev])
}
setIsLoading(false)
});
} catch (error) {
}
}
return (
<Box sx={{
padding: '25px',
display: 'flex',
flexDirection: 'column',
gap: '25px',
maxWidth: '350px',
background: '#4444'
}}>
<LoadingButton loading={isLoading} loadingPosition="start" color="warning" variant='contained' onClick={createCommonSecret}>Re-encyrpt key</LoadingButton>
{noSecretKey ? (
<Box>
<Typography>There is no group secret key. Be the first admin to publish one!</Typography>
</Box>
) : isOwner && secretKeyDetails && userInfo?.name && userInfo.name !== secretKeyDetails?.name ? (
<Box>
<Typography>The latest group secret key was published by a non-owner. As the owner of the group please re-encrypt the key as a safeguard</Typography>
</Box>
): (
<Box>
<Typography>The group member list has changed. Please re-encrypt the secret key.</Typography>
</Box>
)}
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
</Box>
)
}

View File

@@ -0,0 +1,59 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import ResizableImage from './ResizableImage'; // Import your ResizableImage component
const CustomImage = Node.create({
name: 'image',
inline: false,
group: 'block',
draggable: true,
addAttributes() {
return {
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
width: {
default: 'auto',
},
};
},
parseHTML() {
return [
{
tag: 'img[src]',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['img', mergeAttributes(HTMLAttributes)];
},
addNodeView() {
return ReactNodeViewRenderer(ResizableImage);
},
addCommands() {
return {
setImage:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
export default CustomImage;

View File

@@ -0,0 +1,607 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { CreateCommonSecret } from "./CreateCommonSecret";
import { reusableGet } from "../../qdn/publish/pubish";
import { uint8ArrayToObject } from "../../backgroundFunctions/encryption";
import {
base64ToUint8Array,
objectToBase64,
} from "../../qdn/encryption/group-encryption";
import { ChatContainerComp } from "./ChatContainer";
import { ChatList } from "./ChatList";
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import Tiptap from "./TipTap";
import { AuthenticatedContainerInnerTop, CustomButton } from "../../App-styles";
import CircularProgress from "@mui/material/CircularProgress";
import { getBaseApi, getFee } from "../../background";
import { LoadingSnackbar } from "../Snackbar/LoadingSnackbar";
import { Box, Typography } from "@mui/material";
import { Spacer } from "../../common/Spacer";
import ShortUniqueId from "short-unique-id";
import { AnnouncementList } from "./AnnouncementList";
const uid = new ShortUniqueId({ length: 8 });
import CampaignIcon from '@mui/icons-material/Campaign';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { AnnouncementDiscussion } from "./AnnouncementDiscussion";
import { MyContext, getBaseApiReact } from "../../App";
import { RequestQueueWithPromise } from "../../utils/queue/queue";
import { CustomizedSnackbars } from "../Snackbar/Snackbar";
export const requestQueueCommentCount = new RequestQueueWithPromise(3)
export const requestQueuePublishedAccouncements = new RequestQueueWithPromise(3)
export const saveTempPublish = async ({ data, key }: any) => {
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "saveTempPublish",
payload: {
data,
key,
},
},
(response) => {
if (!response?.error) {
res(response);
}
rej(response.error);
}
);
});
};
export const getTempPublish = async () => {
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "getTempPublish",
payload: {
},
},
(response) => {
if (!response?.error) {
res(response);
}
rej(response.error);
}
);
});
};
export const decryptPublishes = async (encryptedMessages: any[], secretKey) => {
try {
return await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "decryptSingleForPublishes",
payload: {
data: encryptedMessages,
secretKeyObject: secretKey,
skipDecodeBase64: true,
},
},
(response) => {
if (!response?.error) {
res(response);
// if(hasInitialized.current){
// setMessages((prev)=> [...prev, ...formatted])
// } else {
// const formatted = response.map((item: any)=> {
// return {
// ...item,
// id: item.signature,
// text: item.text,
// unread: false
// }
// } )
// setMessages(formatted)
// hasInitialized.current = true
// }
}
rej(response.error);
}
);
});
} catch (error) {}
};
export const GroupAnnouncements = ({
selectedGroup,
secretKey,
setSecretKey,
getSecretKey,
myAddress,
handleNewEncryptionNotification,
isAdmin,
hide,
myName
}) => {
const [messages, setMessages] = useState([]);
const [isSending, setIsSending] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [announcements, setAnnouncements] = useState([]);
const [tempPublishedList, setTempPublishedList] = useState([])
const [announcementData, setAnnouncementData] = useState({});
const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
const { show } = React.useContext(MyContext);
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const hasInitialized = useRef(false);
const hasInitializedWebsocket = useRef(false);
const editorRef = useRef(null);
const setEditorRef = (editorInstance) => {
editorRef.current = editorInstance;
};
const getAnnouncementData = async ({ identifier, name }) => {
try {
const res = await requestQueuePublishedAccouncements.enqueue(()=> {
return fetch(
`${getBaseApiReact()}/arbitrary/DOCUMENT/${name}/${identifier}?encoding=base64`
);
})
const data = await res.text();
const response = await decryptPublishes([{ data }], secretKey);
const messageData = response[0];
setAnnouncementData((prev) => {
return {
...prev,
[`${identifier}-${name}`]: messageData,
};
});
} catch (error) {}
};
useEffect(() => {
if (!secretKey || hasInitializedWebsocket.current) return;
setIsLoading(true);
// initWebsocketMessageGroup()
hasInitializedWebsocket.current = true;
}, [secretKey]);
const encryptChatMessage = async (data: string, secretKeyObject: any) => {
try {
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "encryptSingle",
payload: {
data,
secretKeyObject,
},
},
(response) => {
if (!response?.error) {
res(response);
return;
}
rej(response.error);
}
);
});
} catch (error) {}
};
const publishAnc = async ({ encryptedData, identifier }: any) => {
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "publishGroupEncryptedResource",
payload: {
encryptedData,
identifier,
},
},
(response) => {
if (!response?.error) {
res(response);
}
rej(response.error);
}
);
});
};
const clearEditorContent = () => {
if (editorRef.current) {
editorRef.current.chain().focus().clearContent().run();
}
};
const setTempData = async ()=> {
try {
const getTempAnnouncements = await getTempPublish()
if(getTempAnnouncements?.announcement){
let tempData = []
Object.keys(getTempAnnouncements?.announcement || {}).map((key)=> {
const value = getTempAnnouncements?.announcement[key]
tempData.push(value.data)
})
setTempPublishedList(tempData)
}
} catch (error) {
}
}
const publishAnnouncement = async () => {
try {
const fee = await getFee('ARBITRARY')
await show({
message: "Would you like to perform a ARBITRARY transaction?" ,
publishFee: fee.fee + ' QORT'
})
if (isSending) return;
if (editorRef.current) {
const htmlContent = editorRef.current.getHTML();
if (!htmlContent?.trim() || htmlContent?.trim() === "<p></p>") return;
setIsSending(true);
const message = {
version: 1,
extra: {},
message: htmlContent
}
const secretKeyObject = await getSecretKey();
const message64: any = await objectToBase64(message);
const encryptSingle = await encryptChatMessage(
message64,
secretKeyObject
);
const randomUid = uid.rnd();
const identifier = `grp-${selectedGroup}-anc-${randomUid}`;
const res = await publishAnc({
encryptedData: encryptSingle,
identifier
});
const dataToSaveToStorage = {
name: myName,
identifier,
service: 'DOCUMENT',
tempData: message,
created: Date.now()
}
await saveTempPublish({data: dataToSaveToStorage, key: 'announcement'})
setTempData()
clearEditorContent();
}
// send chat message
} catch (error) {
setInfoSnack({
type: "error",
message: error,
});
setOpenSnack(true)
} finally {
setIsSending(false);
}
};
const getAnnouncements = React.useCallback(
async (selectedGroup) => {
try {
const offset = 0;
// dispatch(setIsLoadingGlobal(true))
const identifier = `grp-${selectedGroup}-anc-`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=20&includemetadata=false&offset=${offset}&reverse=true&prefix=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
setTempData()
setAnnouncements(responseData);
setIsLoading(false);
for (const data of responseData) {
getAnnouncementData({ name: data.name, identifier: data.identifier });
}
} catch (error) {
} finally {
// dispatch(setIsLoadingGlobal(false))
}
},
[secretKey]
);
React.useEffect(() => {
if (selectedGroup && secretKey && !hasInitialized.current) {
getAnnouncements(selectedGroup);
hasInitialized.current = true
}
}, [selectedGroup, secretKey]);
const loadMore = async()=> {
try {
setIsLoading(true);
const offset = announcements.length
const identifier = `grp-${selectedGroup}-anc-`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=20&includemetadata=false&offset=${offset}&reverse=true&prefix=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
setAnnouncements((prev)=> [...prev, ...responseData]);
setIsLoading(false);
for (const data of responseData) {
getAnnouncementData({ name: data.name, identifier: data.identifier });
}
} catch (error) {
}
}
const interval = useRef<any>(null)
const checkNewMessages = React.useCallback(
async () => {
try {
const identifier = `grp-${selectedGroup}-anc-`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=20&includemetadata=false&offset=${0}&reverse=true&prefix=true`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
const latestMessage = announcements[0]
if (!latestMessage) {
for (const data of responseData) {
try {
getAnnouncementData({ name: data.name, identifier: data.identifier });
} catch (error) {}
}
setAnnouncements(responseData)
return
}
const findMessage = responseData?.findIndex(
(item: any) => item?.identifier === latestMessage?.identifier
)
if(findMessage === -1) return
const newArray = responseData.slice(0, findMessage)
for (const data of newArray) {
try {
getAnnouncementData({ name: data.name, identifier: data.identifier });
} catch (error) {}
}
setAnnouncements((prev)=> [...newArray, ...prev])
} catch (error) {
} finally {
}
},
[announcements, secretKey, selectedGroup]
)
const checkNewMessagesFunc = useCallback(() => {
let isCalling = false
interval.current = setInterval(async () => {
if (isCalling) return
isCalling = true
const res = await checkNewMessages()
isCalling = false
}, 20000)
}, [checkNewMessages])
useEffect(() => {
if(!secretKey) return
checkNewMessagesFunc()
return () => {
if (interval?.current) {
clearInterval(interval.current)
}
}
}, [checkNewMessagesFunc])
const combinedListTempAndReal = useMemo(() => {
// Combine the two lists
const combined = [...tempPublishedList, ...announcements];
// Remove duplicates based on the "identifier"
const uniqueItems = new Map();
combined.forEach(item => {
uniqueItems.set(item.identifier, item); // This will overwrite duplicates, keeping the last occurrence
});
// Convert the map back to an array and sort by "created" timestamp in descending order
const sortedList = Array.from(uniqueItems.values()).sort((a, b) => b.created - a.created);
return sortedList;
}, [tempPublishedList, announcements]);
if(selectedAnnouncement){
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
width: "100%",
visibility: hide && 'hidden',
position: hide && 'fixed',
left: hide && '-1000px'
}}
>
<AnnouncementDiscussion myName={myName} show={show} secretKey={secretKey} selectedAnnouncement={selectedAnnouncement} setSelectedAnnouncement={setSelectedAnnouncement} encryptChatMessage={encryptChatMessage} getSecretKey={getSecretKey} />
</div>
)
}
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
width: "100%",
visibility: hide && 'hidden',
position: hide && 'fixed',
left: hide && '-1000px'
}}
>
<div style={{
position: "relative",
width: "100%",
display: "flex",
flexDirection: "column",
flexShrink: 0,
}}>
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
padding: "25px",
fontSize: "20px",
gap: '20px',
alignItems: 'center'
}}
>
<CampaignIcon sx={{
fontSize: '30px'
}} />
Group Announcements
</Box>
<Spacer height="25px" />
</div>
{!isLoading && combinedListTempAndReal?.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<Typography sx={{
fontSize: '16px'
}}>No announcements</Typography>
</Box>
)}
<AnnouncementList
announcementData={announcementData}
initialMessages={combinedListTempAndReal}
setSelectedAnnouncement={setSelectedAnnouncement}
disableComment={false}
showLoadMore={announcements.length > 0 && announcements.length % 20 === 0}
loadMore={loadMore}
/>
{isAdmin && (
<div
style={{
// position: 'fixed',
// bottom: '0px',
backgroundColor: "#232428",
minHeight: "150px",
maxHeight: "400px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
width: "100%",
boxSizing: "border-box",
padding: "20px",
flexShrink: 0
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
// height: '100%',
overflow: "auto",
}}
>
<Tiptap
setEditorRef={setEditorRef}
onEnter={publishAnnouncement}
disableEnter
/>
</div>
<CustomButton
onClick={() => {
if (isSending) return;
publishAnnouncement();
}}
style={{
marginTop: "auto",
alignSelf: "center",
cursor: isSending ? "default" : "pointer",
background: isSending && "rgba(0, 0, 0, 0.8)",
flexShrink: 0,
}}
>
{isSending && (
<CircularProgress
size={18}
sx={{
position: "absolute",
top: "50%",
left: "50%",
marginTop: "-12px",
marginLeft: "-12px",
color: "white",
}}
/>
)}
{` Publish Announcement`}
</CustomButton>
</div>
)}
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
<LoadingSnackbar
open={isLoading}
info={{
message: "Loading announcements... please wait.",
}}
/>
</div>
);
};

View File

@@ -0,0 +1,52 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { GroupMail } from "../Group/Forum/GroupMail";
export const GroupForum = ({
selectedGroup,
userInfo,
secretKey,
getSecretKey,
isAdmin,
myAddress,
hide,
defaultThread,
setDefaultThread
}) => {
const [isMoved, setIsMoved] = useState(false);
useEffect(() => {
if (hide) {
setTimeout(() => setIsMoved(true), 300); // Wait for the fade-out to complete before moving
} else {
setIsMoved(false); // Reset the position immediately when showing
}
}, [hide]);
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
width: "100%",
opacity: hide ? 0 : 1,
visibility: hide && 'hidden',
position: hide ? 'fixed' : 'relative',
left: hide && '-1000px'
}}
>
<GroupMail getSecretKey={getSecretKey} selectedGroup={selectedGroup} userInfo={userInfo} secretKey={secretKey} defaultThread={defaultThread} setDefaultThread={setDefaultThread} />
</div>
);
};

View File

@@ -0,0 +1,66 @@
import React, { useEffect } from 'react';
import DOMPurify from 'dompurify';
import './styles.css'; // Ensure this CSS file is imported
export const MessageDisplay = ({ htmlContent }) => {
const linkify = (text) => {
// Regular expression to find URLs starting with https://, http://, or www.
const urlPattern = /(\bhttps?:\/\/[^\s<]+|\bwww\.[^\s<]+)/g;
// Replace plain text URLs with anchor tags
return text.replace(urlPattern, (url) => {
const href = url.startsWith('http') ? url : `https://${url}`;
return `<a href="${href}" class="auto-link">${DOMPurify.sanitize(url)}</a>`;
});
};
// Sanitize and linkify the content
const sanitizedContent = DOMPurify.sanitize(linkify(htmlContent), {
ALLOWED_TAGS: [
'a', 'b', 'i', 'em', 'strong', 'p', 'br', 'div', 'span', 'img',
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
],
ALLOWED_ATTR: [
'href', 'target', 'rel', 'class', 'src', 'alt', 'title',
'width', 'height', 'style', 'align', 'valign', 'colspan', 'rowspan', 'border', 'cellpadding', 'cellspacing'
],
});
// Function to handle link clicks
const handleClick = (e) => {
e.preventDefault();
// Ensure we are targeting an <a> element
const target = e.target.closest('a');
if (target) {
const href = target.getAttribute('href');
if (chrome && chrome.tabs) {
chrome.tabs.create({ url: href }, (tab) => {
if (chrome.runtime.lastError) {
console.error('Error opening tab:', chrome.runtime.lastError);
} else {
console.log('Tab opened successfully:', tab);
}
});
} else {
console.error('chrome.tabs API is not available.');
}
} else {
console.error('No <a> tag found or href is null.');
}
};
return (
<div
className="tiptap"
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
onClick={(e) => {
// Delegate click handling to the parent div
if (e.target.tagName === 'A') {
handleClick(e);
}
}}
/>
);
};

View File

@@ -0,0 +1,93 @@
import { Message } from "@chatscope/chat-ui-kit-react";
import React, { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import { MessageDisplay } from "./MessageDisplay";
import { Avatar, Box, Typography } from "@mui/material";
import { formatTimestamp } from "../../utils/time";
import { getBaseApi } from "../../background";
import { getBaseApiReact } from "../../App";
export const MessageItem = ({ message, onSeen }) => {
const { ref, inView } = useInView({
threshold: 1.0, // Fully visible
triggerOnce: true, // Only trigger once when it becomes visible
});
useEffect(() => {
if (inView && message.unread) {
onSeen(message.id);
}
}, [inView, message.id, message.unread, onSeen]);
return (
<div
ref={ref}
style={{
padding: "10px",
backgroundColor: "#232428",
borderRadius: "7px",
width: "95%",
display: "flex",
gap: '7px',
}}
>
<Avatar
sx={{
backgroundColor: '#27282c',
color: 'white'
}}
alt={message?.senderName}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${message?.senderName}/qortal_avatar?async=true`}
>
{message?.senderName?.charAt(0)}
</Avatar>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "7px",
width: '100%'
}}
>
<Typography
sx={{
fontWight: 600,
fontFamily: "Inter",
color: "cadetBlue",
}}
>
{message?.senderName || message?.sender}
</Typography>
{message?.text?.type === "notification" ? (
<MessageDisplay htmlContent={message.text?.data?.message} />
) : (
<MessageDisplay htmlContent={message.text} />
)}
<Box sx={{
display: 'flex',
justifyContent: 'flex-end',
width: '100%'
}}>
<Typography sx={{
fontSize: '14px',
color: 'gray',
fontFamily: 'Inter'
}}>{formatTimestamp(message.timestamp)}</Typography>
</Box>
</Box>
{/* <Message
model={{
direction: 'incoming',
message: message.text,
position: 'single',
sender: message.senderName,
sentTime: message.timestamp
}}
></Message> */}
{/* {!message.unread && <span style={{ color: 'green' }}> Seen</span>} */}
</div>
);
};

View File

@@ -0,0 +1,63 @@
import React, { useRef } from 'react';
import { NodeViewWrapper } from '@tiptap/react';
const ResizableImage = ({ node, updateAttributes, selected }) => {
const imgRef = useRef(null);
const startResizing = (e) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startWidth = imgRef.current.offsetWidth;
const onMouseMove = (e) => {
const newWidth = startWidth + e.clientX - startX;
updateAttributes({ width: `${newWidth}px` });
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
return (
<NodeViewWrapper
as="div"
className={`resizable-image ${selected ? 'selected' : ''}`}
style={{
display: 'inline-block',
position: 'relative',
userSelect: 'none', // Prevent selection to avoid interference with the text cursor
}}
>
<img
ref={imgRef}
src={node.attrs.src}
alt={node.attrs.alt || ''}
title={node.attrs.title || ''}
style={{ width: node.attrs.width || 'auto', display: 'block', margin: '0 auto' }}
draggable={false} // Prevent image dragging
/>
<div
style={{
position: 'absolute',
right: 0,
bottom: 0,
width: '10px',
height: '10px',
backgroundColor: 'gray',
cursor: 'nwse-resize',
zIndex: 1, // Ensure the resize handle is above other content
}}
onMouseDown={startResizing}
></div>
</NodeViewWrapper>
);
};
export default ResizableImage;

View File

@@ -0,0 +1,292 @@
import React, { useEffect, useRef } from 'react';
import { EditorProvider, useCurrentEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { Color } from '@tiptap/extension-color';
import ListItem from '@tiptap/extension-list-item';
import TextStyle from '@tiptap/extension-text-style';
import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image';
import IconButton from '@mui/material/IconButton';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import FormatItalicIcon from '@mui/icons-material/FormatItalic';
import StrikethroughSIcon from '@mui/icons-material/StrikethroughS';
import FormatClearIcon from '@mui/icons-material/FormatClear';
import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
import CodeIcon from '@mui/icons-material/Code';
import ImageIcon from '@mui/icons-material/Image'; // Import Image icon
import FormatQuoteIcon from '@mui/icons-material/FormatQuote';
import HorizontalRuleIcon from '@mui/icons-material/HorizontalRule';
import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
import FormatHeadingIcon from '@mui/icons-material/FormatSize';
import DeveloperModeIcon from '@mui/icons-material/DeveloperMode';
import CustomImage from './CustomImage';
import Compressor from 'compressorjs'
import ImageResize from 'tiptap-extension-resize-image'; // Import the ResizeImage extension
const MenuBar = ({ setEditorRef }) => {
const { editor } = useCurrentEditor();
const fileInputRef = useRef(null);
if (!editor) {
return null;
}
useEffect(() => {
if (editor && setEditorRef) {
setEditorRef(editor);
}
}, [editor, setEditorRef]);
const handleImageUpload = async (event) => {
const file = event.target.files[0];
let compressedFile
await new Promise<void>((resolve) => {
new Compressor(file, {
quality: 0.6,
maxWidth: 1200,
mimeType: 'image/webp',
success(result) {
const file = new File([result], 'name', {
type: 'image/webp'
})
compressedFile = file
resolve()
},
error(err) {}
})
})
if (compressedFile) {
const reader = new FileReader();
reader.onload = () => {
const url = reader.result;
editor.chain().focus().setImage({ src: url , style: "width: auto"}).run();
fileInputRef.current.value = '';
};
reader.readAsDataURL(compressedFile);
}
};
const triggerImageUpload = () => {
fileInputRef.current.click(); // Trigger the file input click
};
return (
<div className="control-group">
<div className="button-group">
<IconButton
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={
!editor.can()
.chain()
.focus()
.toggleBold()
.run()
}
// color={editor.isActive('bold') ? 'white' : 'gray'}
sx={{
color: editor.isActive('bold') ? 'white' : 'gray'
}}
>
<FormatBoldIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={
!editor.can()
.chain()
.focus()
.toggleItalic()
.run()
}
// color={editor.isActive('italic') ? 'white' : 'gray'}
sx={{
color: editor.isActive('italic') ? 'white' : 'gray'
}}
>
<FormatItalicIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={
!editor.can()
.chain()
.focus()
.toggleStrike()
.run()
}
// color={editor.isActive('strike') ? 'white' : 'gray'}
sx={{
color: editor.isActive('strike') ? 'white' : 'gray'
}}
>
<StrikethroughSIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={
!editor.can()
.chain()
.focus()
.toggleCode()
.run()
}
// color={editor.isActive('code') ? 'white' : 'gray'}
sx={{
color: editor.isActive('code') ? 'white' : 'gray'
}}
>
<CodeIcon />
</IconButton>
<IconButton onClick={() => editor.chain().focus().unsetAllMarks().run()}>
<FormatClearIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
// color={editor.isActive('bulletList') ? 'white' : 'gray'}
sx={{
color: editor.isActive('bulletList') ? 'white' : 'gray'
}}
>
<FormatListBulletedIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
// color={editor.isActive('orderedList') ? 'white' : 'gray'}
sx={{
color: editor.isActive('orderedList') ? 'white' : 'gray'
}}
>
<FormatListNumberedIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
// color={editor.isActive('codeBlock') ? 'white' : 'gray'}
sx={{
color: editor.isActive('codeBlock') ? 'white' : 'gray'
}}
>
<DeveloperModeIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
// color={editor.isActive('blockquote') ? 'white' : 'gray'}
sx={{
color: editor.isActive('blockquote') ? 'white' : 'gray'
}}
>
<FormatQuoteIcon />
</IconButton>
<IconButton onClick={() => editor.chain().focus().setHorizontalRule().run()}>
<HorizontalRuleIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
// color={editor.isActive('heading', { level: 1 }) ? 'white' : 'gray'}
sx={{
color: editor.isActive('heading', { level: 1 }) ? 'white' : 'gray'
}}
>
<FormatHeadingIcon fontSize="small" />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().undo().run()}
disabled={
!editor.can()
.chain()
.focus()
.undo()
.run()
}
sx={{
color: 'gray'
}}
>
<UndoIcon />
</IconButton>
<IconButton
sx={{
color: 'gray'
}}
onClick={() => editor.chain().focus().redo().run()}
disabled={
!editor.can()
.chain()
.focus()
.redo()
.run()
}
>
<RedoIcon />
</IconButton>
<IconButton
onClick={triggerImageUpload}
sx={{
color: 'gray'
}}
>
<ImageIcon />
</IconButton>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleImageUpload}
accept="image/*" // Limit file types to images only
/>
</div>
</div>
);
};
const extensions = [
Color.configure({ types: [TextStyle.name, ListItem.name] }),
TextStyle.configure({ types: [ListItem.name] }),
StarterKit.configure({
bulletList: {
keepMarks: true,
keepAttributes: false,
},
orderedList: {
keepMarks: true,
keepAttributes: false,
},
}),
Placeholder.configure({
placeholder: 'Start typing here...', // Add your placeholder text here
}),
ImageResize,
];
const content = ``;
export default ({ setEditorRef, onEnter, disableEnter }) => {
return (
<EditorProvider
slotBefore={<MenuBar setEditorRef={setEditorRef} />}
extensions={extensions}
content={content}
editorProps={{
handleKeyDown(view, event) {
if (!disableEnter && event.key === 'Enter') {
if (event.shiftKey) {
// Shift+Enter: Insert a hard break
view.dispatch(view.state.tr.replaceSelectionWith(view.state.schema.nodes.hardBreak.create()));
return true;
} else {
// Enter: Call the callback function
if (typeof onEnter === 'function') {
onEnter();
}
return true; // Prevent the default action of adding a new line
}
}
return false; // Allow default handling for other keys
},
}}
/>
);
};

View File

@@ -0,0 +1,121 @@
.tiptap {
margin-top: 0;
color: white; /* Set default font color to white */
width: 100%;
}
.tiptap ul,
.tiptap ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
}
.tiptap ul li p,
.tiptap ol li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
/* Heading styles */
.tiptap h1,
.tiptap h2,
.tiptap h3,
.tiptap h4,
.tiptap h5,
.tiptap h6 {
line-height: 1.1;
margin-top: 2.5rem;
text-wrap: pretty;
color: white; /* Ensure heading font color is white */
}
.tiptap h1,
.tiptap h2 {
margin-top: 3.5rem;
margin-bottom: 1.5rem;
}
.tiptap h1 {
font-size: 1.4rem;
}
.tiptap h2 {
font-size: 1.2rem;
}
.tiptap h3 {
font-size: 1.1rem;
}
.tiptap h4,
.tiptap h5,
.tiptap h6 {
font-size: 1rem;
}
/* Code and preformatted text styles */
.tiptap code {
background-color: #27282c; /* Set code background color to #27282c */
border-radius: 0.4rem;
color: white; /* Ensure inline code text color is white */
font-size: 0.85rem;
padding: 0.25em 0.3em;
text-wrap: pretty;
}
.tiptap pre {
background: #27282c; /* Set code block background color to #27282c */
border-radius: 0.5rem;
color: white; /* Ensure code block text color is white */
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
outline: none;
}
.tiptap pre code {
background: none;
color: inherit; /* Inherit text color from the parent pre block */
font-size: 0.8rem;
padding: 0;
text-wrap: pretty;
}
.tiptap blockquote {
border-left: 3px solid var(--gray-3);
margin: 1.5rem 0;
padding-left: 1rem;
color: white; /* Ensure blockquote text color is white */
text-wrap: pretty;
}
.tiptap hr {
border: none;
border-top: 1px solid var(--gray-2);
margin: 2rem 0;
}
.ProseMirror:focus-visible {
outline: none !important;
}
.tiptap p {
font-size: 16px;
color: white; /* Ensure paragraph text color is white */
}
.tiptap p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap a {
color: cadetblue
}
.tiptap img {
display: block;
max-width: 100%;
}