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%;
}

View File

@@ -0,0 +1,474 @@
import * as React from "react";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import ListItemText from "@mui/material/ListItemText";
import ListItemButton from "@mui/material/ListItemButton";
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import CloseIcon from "@mui/icons-material/Close";
import ExpandLess from "@mui/icons-material/ExpandLess";
import ExpandMore from "@mui/icons-material/ExpandMore";
import Slide from "@mui/material/Slide";
import { TransitionProps } from "@mui/material/transitions";
import {
Box,
Collapse,
Input,
MenuItem,
Select,
SelectChangeEvent,
Tab,
Tabs,
styled,
} from "@mui/material";
import { AddGroupList } from "./AddGroupList";
import { UserListOfInvites } from "./UserListOfInvites";
import { CustomizedSnackbars } from "../Snackbar/Snackbar";
import { getFee } from "../../background";
import { MyContext } from "../../App";
import { subscribeToEvent, unsubscribeFromEvent } from "../../utils/events";
export const Label = styled("label")(
({ theme }) => `
font-family: 'IBM Plex Sans', sans-serif;
font-size: 14px;
display: block;
margin-bottom: 4px;
font-weight: 400;
`
);
const Transition = React.forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement;
},
ref: React.Ref<unknown>
) {
return <Slide direction="up" ref={ref} {...props} />;
});
export const AddGroup = ({ address, open, setOpen }) => {
const {show, setTxList} = React.useContext(MyContext)
const [tab, setTab] = React.useState("create");
const [openAdvance, setOpenAdvance] = React.useState(false);
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [groupType, setGroupType] = React.useState("1");
const [approvalThreshold, setApprovalThreshold] = React.useState("40");
const [minBlock, setMinBlock] = React.useState("5");
const [maxBlock, setMaxBlock] = React.useState("21600");
const [value, setValue] = React.useState(0);
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
const handleClose = () => {
setOpen(false);
};
const handleChangeGroupType = (event: SelectChangeEvent) => {
setGroupType(event.target.value as string);
};
const handleChangeApprovalThreshold = (event: SelectChangeEvent) => {
setGroupType(event.target.value as string);
};
const handleChangeMinBlock = (event: SelectChangeEvent) => {
setMinBlock(event.target.value as string);
};
const handleChangeMaxBlock = (event: SelectChangeEvent) => {
setMaxBlock(event.target.value as string);
};
const handleCreateGroup = async () => {
try {
if(!name) throw new Error('Please provide a name')
if(!description) throw new Error('Please provide a description')
const fee = await getFee('CREATE_GROUP')
await show({
message: "Would you like to perform an CREATE_GROUP transaction?" ,
publishFee: fee.fee + ' QORT'
})
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "createGroup",
payload: {
groupName: name,
groupDescription: description,
groupType: +groupType,
groupApprovalThreshold: +approvalThreshold,
minBlock: +minBlock,
maxBlock: +maxBlock,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message: "Successfully created group. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
setTxList((prev)=> [{
...response,
type: 'created-group',
label: `Created group ${name}: awaiting confirmation`,
labelDone: `Created group ${name}: success !`,
done: false
}, ...prev])
res(response);
return
}
rej({message: response.error});
}
);
});
} catch (error) {
setInfoSnack({
type: "error",
message: error?.message,
});
setOpenSnack(true);
}
};
function CustomTabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
</div>
);
}
function a11yProps(index: number) {
return {
id: `simple-tab-${index}`,
"aria-controls": `simple-tabpanel-${index}`,
};
}
const openGroupInvitesRequestFunc = ()=> {
setValue(2)
}
React.useEffect(() => {
subscribeToEvent("openGroupInvitesRequest", openGroupInvitesRequestFunc);
return () => {
unsubscribeFromEvent("openGroupInvitesRequest", openGroupInvitesRequestFunc);
};
}, []);
return (
<React.Fragment>
<Dialog
fullScreen
open={open}
onClose={handleClose}
TransitionComponent={Transition}
>
<AppBar sx={{ position: "relative", bgcolor: "#232428" }}>
<Toolbar>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
Add Group
</Typography>
<IconButton
edge="start"
color="inherit"
onClick={handleClose}
aria-label="close"
>
<CloseIcon />
</IconButton>
{/* <Button autoFocus color="inherit" onClick={handleClose}>
save
</Button> */}
</Toolbar>
</AppBar>
<Box
sx={{
bgcolor: "#27282c",
flexGrow: 1,
overflowY: "auto",
color: "white",
}}
>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs
sx={{
"& .MuiTabs-indicator": {
backgroundColor: "white",
},
}}
value={value}
onChange={handleChange}
aria-label="basic tabs example"
>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="Create Group"
{...a11yProps(0)}
/>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="Find Group"
{...a11yProps(1)}
/>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="Group Invites"
{...a11yProps(2)}
/>
</Tabs>
</Box>
{value === 0 && (
<Box sx={{
width: '100%',
padding: '25px'
}}>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "20px",
maxWidth: "500px",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "5px",
}}
>
<Label>Name of group</Label>
<Input
placeholder="Name of group"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "5px",
}}
>
<Label>Description of group</Label>
<Input
placeholder="Description of group"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "5px",
}}
>
<Label>Group type</Label>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={groupType}
label="Group Type"
onChange={handleChangeGroupType}
>
<MenuItem value={1}>Open (public)</MenuItem>
<MenuItem value={0}>
Closed (private) - users need permission to join
</MenuItem>
</Select>
</Box>
<Box
sx={{
display: "flex",
gap: "15px",
alignItems: "center",
cursor: "pointer",
}}
onClick={() => setOpenAdvance((prev) => !prev)}
>
<Typography>Advanced options</Typography>
{openAdvance ? <ExpandLess /> : <ExpandMore />}
</Box>
<Collapse in={openAdvance} timeout="auto" unmountOnExit>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "5px",
}}
>
<Label>
Group Approval Threshold (number / percentage of Admins that
must approve a transaction)
</Label>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={approvalThreshold}
label="Group Approval Threshold"
onChange={handleChangeApprovalThreshold}
>
<MenuItem value={0}>NONE</MenuItem>
<MenuItem value={1}>ONE </MenuItem>
<MenuItem value={20}>20% </MenuItem>
<MenuItem value={40}>40% </MenuItem>
<MenuItem value={60}>60% </MenuItem>
<MenuItem value={80}>80% </MenuItem>
<MenuItem value={100}>100% </MenuItem>
</Select>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "5px",
}}
>
<Label>
Minimum Block delay for Group Transaction Approvals
</Label>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={minBlock}
label="Minimum Block delay"
onChange={handleChangeMinBlock}
>
<MenuItem value={5}>5 minutes</MenuItem>
<MenuItem value={10}>10 minutes</MenuItem>
<MenuItem value={30}>30 minutes</MenuItem>
<MenuItem value={60}>1 hour</MenuItem>
<MenuItem value={180}>3 hours</MenuItem>
<MenuItem value={300}>5 hours</MenuItem>
<MenuItem value={420}>7 hours</MenuItem>
<MenuItem value={720}>12 hours</MenuItem>
<MenuItem value={1440}>1 day</MenuItem>
<MenuItem value={4320}>3 days</MenuItem>
<MenuItem value={7200}>5 days</MenuItem>
<MenuItem value={10080}>7 days</MenuItem>
</Select>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "5px",
}}
>
<Label>
Maximum Block delay for Group Transaction Approvals
</Label>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={maxBlock}
label="Maximum Block delay"
onChange={handleChangeMaxBlock}
>
<MenuItem value={60}>1 hour</MenuItem>
<MenuItem value={180}>3 hours</MenuItem>
<MenuItem value={300}>5 hours</MenuItem>
<MenuItem value={420}>7 hours</MenuItem>
<MenuItem value={720}>12 hours</MenuItem>
<MenuItem value={1440}>1 day</MenuItem>
<MenuItem value={4320}>3 days</MenuItem>
<MenuItem value={7200}>5 days</MenuItem>
<MenuItem value={10080}>7 days</MenuItem>
<MenuItem value={14400}>10 days</MenuItem>
<MenuItem value={21600}>15 days</MenuItem>
</Select>
</Box>
</Collapse>
<Box
sx={{
display: "flex",
width: "100%",
justifyContent: "center",
}}
>
<Button
variant="contained"
color="primary"
onClick={handleCreateGroup}
>
Create Group
</Button>
</Box>
</Box>
</Box>
)}
{value === 1 && (
<Box sx={{
width: '100%',
padding: '25px'
}}>
<AddGroupList setOpenSnack={setOpenSnack} setInfoSnack={setInfoSnack} />
</Box>
)}
{value === 2 && (
<Box sx={{
width: '100%',
padding: '25px'
}}>
<UserListOfInvites myAddress={address} setOpenSnack={setOpenSnack} setInfoSnack={setInfoSnack} />
</Box>
)}
</Box>
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
</Dialog>
</React.Fragment>
);
};

View File

@@ -0,0 +1,272 @@
import {
Box,
Button,
ListItem,
ListItemButton,
ListItemText,
Popover,
TextField,
Typography,
} from "@mui/material";
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
AutoSizer,
CellMeasurer,
CellMeasurerCache,
List,
} from "react-virtualized";
import _ from "lodash";
import { MyContext, getBaseApiReact } from "../../App";
import { LoadingButton } from "@mui/lab";
import { getBaseApi, getFee } from "../../background";
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const AddGroupList = ({ setInfoSnack, setOpenSnack }) => {
const { memberGroups, show, setTxList } = useContext(MyContext);
const [groups, setGroups] = useState([]);
const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to
const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Track which list item has the popover open
const listRef = useRef();
const [inputValue, setInputValue] = useState("");
const [filteredItems, setFilteredItems] = useState(groups);
const [isLoading, setIsLoading] = useState(false);
const handleFilter = useCallback(
(query) => {
if (query) {
setFilteredItems(
groups.filter((item) =>
item.groupName.toLowerCase().includes(query.toLowerCase())
)
);
} else {
setFilteredItems(groups);
}
},
[groups]
);
const debouncedFilter = useMemo(
() => _.debounce(handleFilter, 500),
[handleFilter]
);
const handleChange = (event) => {
const value = event.target.value;
setInputValue(value);
debouncedFilter(value);
};
const getGroups = async () => {
try {
const response = await fetch(
`${getBaseApiReact()}/groups/?limit=0`
);
const groupData = await response.json();
const filteredGroup = groupData.filter(
(item) => !memberGroups.find((group) => group.groupId === item.groupId)
);
setGroups(filteredGroup);
setFilteredItems(filteredGroup);
} catch (error) {
console.error(error);
}
};
useEffect(() => {
getGroups();
}, [memberGroups]);
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const handleJoinGroup = async (group, isOpen) => {
try {
const groupId = group.groupId;
const fee = await getFee('JOIN_GROUP')
await show({
message: "Would you like to perform an JOIN_GROUP transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoading(true);
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "joinGroup",
payload: {
groupId,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message: "Successfully requested to join group. It may take a couple of minutes for the changes to propagate",
});
if(isOpen){
setTxList((prev)=> [{
...response,
type: 'joined-group',
label: `Joined Group ${group?.groupName}: awaiting confirmation`,
labelDone: `Joined Group ${group?.groupName}: success !`,
done: false,
groupId,
}, ...prev])
} else {
setTxList((prev)=> [{
...response,
type: 'joined-group-request',
label: `Requested to join Group ${group?.groupName}: awaiting confirmation`,
labelDone: `Requested to join Group ${group?.groupName}: success !`,
done: false,
groupId,
}, ...prev])
}
setOpenSnack(true);
handlePopoverClose();
res(response);
return;
} else {
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
}
);
});
setIsLoading(false);
} catch (error) {} finally {
setIsLoading(false);
}
};
const rowRenderer = ({ index, key, parent, style }) => {
const group = filteredItems[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style} onLoad={measure}>
<ListItem disablePadding>
<Popover
open={openPopoverIndex === index}
anchorEl={popoverAnchor}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
style={{ marginTop: "8px" }}
>
<Box
sx={{
width: "325px",
height: "250px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
padding: "10px",
}}
>
<Typography>Join {group?.groupName}</Typography>
<Typography>
{group?.isOpen === false &&
"This is a closed/private group, so you will need to wait until an admin accepts your request"}
</Typography>
<LoadingButton
loading={isLoading}
loadingPosition="start"
variant="contained"
onClick={() => handleJoinGroup(group, group?.isOpen)}
>
Join group
</LoadingButton>
</Box>
</Popover>
<ListItemButton
onClick={(event) => handlePopoverOpen(event, index)}
>
<ListItemText
primary={group?.groupName}
secondary={group?.description}
/>
</ListItemButton>
</ListItem>
</div>
)}
</CellMeasurer>
);
};
return (
<div>
<p>Groups list</p>
<TextField
label="Search for Groups"
variant="outlined"
fullWidth
value={inputValue}
onChange={handleChange}
/>
<div
style={{
position: "relative",
height: "500px",
width: "600px",
display: "flex",
flexDirection: "column",
flexShrink: 1,
}}
>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={filteredItems.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import { useMemo } from "react";
import DOMPurify from "dompurify";
import "react-quill/dist/quill.snow.css";
import "react-quill/dist/quill.core.css";
import "react-quill/dist/quill.bubble.css";
import { Box, styled } from "@mui/material";
import { convertQortalLinks } from "../../../utils/qortalLink";
const CrowdfundInlineContent = styled(Box)(({ theme }) => ({
display: "flex",
fontFamily: "Mulish",
fontSize: "19px",
fontWeight: 400,
letterSpacing: 0,
color: theme.palette.text.primary,
width: '100%'
}));
export const DisplayHtml = ({ html, textColor }: any) => {
const cleanContent = useMemo(() => {
if (!html) return null;
const sanitize: string = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
const anchorQortal = convertQortalLinks(sanitize);
return anchorQortal;
}, [html]);
if (!cleanContent) return null;
return (
<CrowdfundInlineContent>
<div
className="ql-editor-display"
style={{
color: textColor || 'white',
fontWeight: 400,
fontSize: '16px'
}}
dangerouslySetInnerHTML={{ __html: cleanContent }}
/>
</CrowdfundInlineContent>
);
};

View File

@@ -0,0 +1,777 @@
import React, {
FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Avatar, Box, Popover, Typography } from "@mui/material";
// import { MAIL_SERVICE_TYPE, THREAD_SERVICE_TYPE } from "../../constants/mail";
import { Thread } from "./Thread";
import {
AllThreadP,
ArrowDownIcon,
ComposeContainer,
ComposeContainerBlank,
ComposeIcon,
ComposeP,
GroupContainer,
GroupNameP,
InstanceFooter,
InstanceListContainer,
InstanceListContainerRow,
InstanceListContainerRowCheck,
InstanceListContainerRowCheckIcon,
InstanceListContainerRowMain,
InstanceListContainerRowMainP,
InstanceListHeader,
InstanceListParent,
SelectInstanceContainerFilterInner,
SingleThreadParent,
ThreadContainer,
ThreadContainerFullWidth,
ThreadInfoColumn,
ThreadInfoColumnNameP,
ThreadInfoColumnTime,
ThreadInfoColumnbyP,
ThreadSingleLastMessageP,
ThreadSingleLastMessageSpanP,
ThreadSingleTitle,
} from "./Mail-styles";
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import { Spacer } from "../../../common/Spacer";
import { formatDate, formatTimestamp } from "../../../utils/time";
import LazyLoad from "../../../common/LazyLoad";
import { delay } from "../../../utils/helpers";
import { NewThread } from "./NewThread";
import { getBaseApi } from "../../../background";
import { decryptPublishes, getTempPublish } from "../../Chat/GroupAnnouncements";
import CheckSVG from "../../../assets/svgs/Check.svg";
import SortSVG from "../../../assets/svgs/Sort.svg";
import ArrowDownSVG from "../../../assets/svgs/ArrowDown.svg";
import { LoadingSnackbar } from "../../Snackbar/LoadingSnackbar";
import { executeEvent, subscribeToEvent, unsubscribeFromEvent } from "../../../utils/events";
import RefreshIcon from '@mui/icons-material/Refresh';
import { getBaseApiReact } from "../../../App";
const filterOptions = ["Recently active", "Newest", "Oldest"];
export const threadIdentifier = "DOCUMENT";
export const GroupMail = ({
selectedGroup,
userInfo,
getSecretKey,
secretKey,
defaultThread,
setDefaultThread
}) => {
const [viewedThreads, setViewedThreads] = React.useState<any>({});
const [filterMode, setFilterMode] = useState<string>("Recently active");
const [currentThread, setCurrentThread] = React.useState(null);
const [recentThreads, setRecentThreads] = useState<any[]>([]);
const [allThreads, setAllThreads] = useState<any[]>([]);
const [members, setMembers] = useState<any>(null);
const [isOpenFilterList, setIsOpenFilterList] = useState<boolean>(false);
const anchorElInstanceFilter = useRef<any>(null);
const [tempPublishedList, setTempPublishedList] = useState([])
const [isLoading, setIsLoading] = useState(false)
const groupIdRef = useRef<any>(null);
const groupId = useMemo(() => {
return selectedGroup?.groupId;
}, [selectedGroup]);
useEffect(() => {
if (groupId !== groupIdRef?.current) {
setCurrentThread(null);
setRecentThreads([]);
setAllThreads([]);
groupIdRef.current = groupId;
}
}, [groupId]);
const setTempData = async ()=> {
try {
const getTempAnnouncements = await getTempPublish()
if(getTempAnnouncements?.thread){
let tempData = []
Object.keys(getTempAnnouncements?.thread || {}).map((key)=> {
const value = getTempAnnouncements?.thread[key]
tempData.push(value.data)
})
setTempPublishedList(tempData)
}
} catch (error) {
}
}
const getEncryptedResource = async ({ name, identifier }) => {
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];
return messageData.decryptedData;
};
const updateThreadActivity = async ({threadId, qortalName, groupId, thread}) => {
try {
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "updateThreadActivity",
payload: {
threadId, qortalName, groupId, thread
},
},
(response) => {
if (!response?.error) {
res(response);
return
}
rej(response.error);
}
);
});
} catch (error) {
} finally {
}
};
const getAllThreads = React.useCallback(
async (groupId: string, mode: string, isInitial?: boolean) => {
try {
setIsLoading(true)
const offset = isInitial ? 0 : allThreads.length;
const isReverse = mode === "Newest" ? true : false;
if (isInitial) {
// dispatch(setIsLoadingCustom("Loading threads"));
}
const identifier = `grp-${groupId}-thread-`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=${20}&includemetadata=false&offset=${offset}&reverse=${isReverse}&prefix=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
let fullArrayMsg = isInitial ? [] : [...allThreads];
const getMessageForThreads = responseData.map(async (message: any) => {
let fullObject: any = null;
if (message?.metadata?.description) {
fullObject = {
...message,
threadData: {
title: message?.metadata?.description,
groupId: groupId,
createdAt: message?.created,
name: message?.name,
},
threadOwner: message?.name,
};
} else {
let threadRes = null;
try {
threadRes = await Promise.race([
getEncryptedResource({
name: message.name,
identifier: message.identifier,
}),
delay(5000),
]);
} catch (error) {}
if (threadRes?.title) {
fullObject = {
...message,
threadData: threadRes,
threadOwner: message?.name,
threadId: message.identifier
};
}
}
if (fullObject?.identifier) {
const index = fullArrayMsg.findIndex(
(p) => p.identifier === fullObject.identifier
);
if (index !== -1) {
fullArrayMsg[index] = fullObject;
} else {
fullArrayMsg.push(fullObject);
}
}
});
await Promise.all(getMessageForThreads);
let sorted = fullArrayMsg;
if (isReverse) {
sorted = fullArrayMsg.sort((a: any, b: any) => b.created - a.created);
} else {
sorted = fullArrayMsg.sort((a: any, b: any) => a.created - b.created);
}
setAllThreads(sorted);
} catch (error) {
console.log({ error });
} finally {
if (isInitial) {
setIsLoading(false)
// dispatch(setIsLoadingCustom(null));
}
}
},
[allThreads]
);
const getMailMessages = React.useCallback(
async (groupId: string, members: any) => {
try {
setIsLoading(true)
// const memberNames = Object.keys(members);
// const queryString = memberNames
// .map(name => `&name=${encodeURIComponent(name)}`)
// .join("");
// dispatch(setIsLoadingCustom("Loading recent threads"));
const identifier = `thmsg-grp-${groupId}-thread-`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=100&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 messagesForThread: any = {};
for (const message of responseData) {
let str = message.identifier;
const parts = str.split("-");
// Get the second last element
const secondLastId = parts[parts.length - 2];
const result = `grp-${groupId}-thread-${secondLastId}`;
const checkMessage = messagesForThread[result];
if (!checkMessage) {
messagesForThread[result] = message;
}
}
const newArray = Object.keys(messagesForThread)
.map((key) => {
return {
...messagesForThread[key],
threadId: key,
};
})
.sort((a, b) => b.created - a.created)
.slice(0, 10);
let fullThreadArray: any = [];
const getMessageForThreads = newArray.map(async (message: any) => {
try {
const identifierQuery = message.threadId;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifierQuery}&limit=1&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();
if (responseData.length > 0) {
const thread = responseData[0];
if (thread?.metadata?.description) {
const fullObject = {
...message,
threadData: {
title: thread?.metadata?.description,
groupId: groupId,
createdAt: thread?.created,
name: thread?.name,
},
threadOwner: thread?.name,
};
fullThreadArray.push(fullObject);
} else {
let threadRes = await Promise.race([
getEncryptedResource({
name: thread.name,
identifier: message.threadId,
}),
delay(10000),
]);
if (threadRes?.title) {
const fullObject = {
...message,
threadData: threadRes,
threadOwner: thread?.name,
};
fullThreadArray.push(fullObject);
}
}
}
} catch (error) {
console.log(error);
}
return null;
});
await Promise.all(getMessageForThreads);
const sorted = fullThreadArray.sort(
(a: any, b: any) => b.created - a.created
);
setRecentThreads(sorted);
} catch (error) {
} finally {
setIsLoading(false)
// dispatch(setIsLoadingCustom(null));
}
},
[secretKey]
);
const getMessages = React.useCallback(async () => {
// if ( !groupId || members?.length === 0) return;
if (!groupId) return;
await getMailMessages(groupId, members);
}, [getMailMessages, groupId, members, secretKey]);
const interval = useRef<any>(null);
const firstMount = useRef(false);
const filterModeRef = useRef("");
useEffect(() => {
if (filterModeRef.current !== filterMode) {
firstMount.current = false;
}
// if (groupId && !firstMount.current && members.length > 0) {
if (groupId && !firstMount.current) {
if (filterMode === "Recently active") {
getMessages();
} else if (filterMode === "Newest") {
getAllThreads(groupId, "Newest", true);
} else if (filterMode === "Oldest") {
getAllThreads(groupId, "Oldest", true);
}
setTempData()
firstMount.current = true;
}
}, [groupId, members, filterMode]);
const closeThread = useCallback(() => {
setCurrentThread(null);
}, []);
const getGroupMembers = useCallback(async (groupNumber: string) => {
try {
const response = await fetch(`/groups/members/${groupNumber}?limit=0`);
const groupData = await response.json();
let members: any = {};
if (groupData && Array.isArray(groupData?.members)) {
for (const member of groupData.members) {
if (member.member) {
// const res = await getNameInfo(member.member);
// const resAddress = await qortalRequest({
// action: "GET_ACCOUNT_DATA",
// address: member.member,
// });
const name = res;
const publicKey = resAddress.publicKey;
if (name) {
members[name] = {
publicKey,
address: member.member,
};
}
}
}
}
setMembers(members);
} catch (error) {
console.log({ error });
}
}, []);
// useEffect(() => {
// if(groupId){
// getGroupMembers(groupId);
// interval.current = setInterval(async () => {
// getGroupMembers(groupId);
// }, 180000)
// }
// return () => {
// if (interval?.current) {
// clearInterval(interval.current)
// }
// }
// }, [getGroupMembers, groupId]);
let listOfThreadsToDisplay = recentThreads;
if (filterMode === "Newest" || filterMode === "Oldest") {
listOfThreadsToDisplay = allThreads;
}
const onSubmitNewThread = useCallback(
(val: any) => {
if (filterMode === "Recently active") {
setRecentThreads((prev) => [val, ...prev]);
} else if (filterMode === "Newest") {
setAllThreads((prev) => [val, ...prev]);
}
},
[filterMode]
);
// useEffect(()=> {
// if(user?.name){
// const threads = JSON.parse(
// localStorage.getItem(`qmail_threads_viewedtimestamp_${user.name}`) || "{}"
// );
// setViewedThreads(threads)
// }
// }, [user?.name, currentThread])
const handleCloseThreadFilterList = () => {
setIsOpenFilterList(false);
};
const refetchThreadsLists = useCallback(()=> {
if (filterMode === "Recently active") {
getMessages();
} else if (filterMode === "Newest") {
getAllThreads(groupId, "Newest", true);
} else if (filterMode === "Oldest") {
getAllThreads(groupId, "Oldest", true);
}
}, [filterMode])
const updateThreadActivityCurrentThread = ()=> {
if(!currentThread) return
const thread = currentThread
updateThreadActivity({
threadId: thread?.threadId, qortalName: thread?.threadData?.name, groupId: groupId, thread: thread
})
}
const setThreadFunc = (data)=> {
const thread = data
setCurrentThread(thread);
if(thread?.threadId && thread?.threadData?.name){
updateThreadActivity({
threadId: thread?.threadId, qortalName: thread?.threadData?.name, groupId: groupId, thread: thread
})
}
setTimeout(() => {
executeEvent("threadFetchMode", {
mode: "last-page"
});
}, 300);
}
useEffect(()=> {
if(defaultThread){
setThreadFunc(defaultThread)
setDefaultThread(null)
}
}, [defaultThread])
const combinedListTempAndReal = useMemo(() => {
// Combine the two lists
const transformTempPublishedList = tempPublishedList.map((item)=> {
return {
...item,
threadData: item.tempData,
threadOwner: item?.name,
threadId: item.identifier
}
})
const combined = [...transformTempPublishedList, ...listOfThreadsToDisplay];
// Remove duplicates based on the "identifier"
const uniqueItems = new Map();
combined.forEach(item => {
uniqueItems.set(item.threadId, 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.threadData?.createdAt - a.threadData?.createdAt);
return sortedList;
}, [tempPublishedList, listOfThreadsToDisplay]);
if (currentThread)
return (
<Thread
currentThread={currentThread}
groupInfo={selectedGroup}
closeThread={closeThread}
members={members}
userInfo={userInfo}
secretKey={secretKey}
getSecretKey={getSecretKey}
updateThreadActivityCurrentThread={updateThreadActivityCurrentThread}
/>
);
return (
<GroupContainer
sx={{
position: "relative",
overflow: "auto",
width: "100%",
}}
>
<Popover
open={isOpenFilterList}
anchorEl={anchorElInstanceFilter.current}
onClose={handleCloseThreadFilterList}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<InstanceListParent
sx={{
minHeight: "unset",
width: "auto",
padding: "0px",
}}
>
<InstanceListHeader></InstanceListHeader>
<InstanceListContainer>
{filterOptions?.map((filter) => {
return (
<InstanceListContainerRow
onClick={() => {
setFilterMode(filter);
}}
sx={{
backgroundColor:
filterMode === filter ? "rgba(74, 158, 244, 1)" : "unset",
}}
key={filter}
>
<InstanceListContainerRowCheck>
{filter === filterMode && (
<InstanceListContainerRowCheckIcon src={CheckSVG} />
)}
</InstanceListContainerRowCheck>
<InstanceListContainerRowMain>
<InstanceListContainerRowMainP>
{filter}
</InstanceListContainerRowMainP>
</InstanceListContainerRowMain>
</InstanceListContainerRow>
);
})}
</InstanceListContainer>
<InstanceFooter></InstanceFooter>
</InstanceListParent>
</Popover>
<ThreadContainerFullWidth>
<ThreadContainer>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<NewThread
groupInfo={selectedGroup}
refreshLatestThreads={getMessages}
members={members}
publishCallback={setTempData}
userInfo={userInfo}
getSecretKey={getSecretKey}
myName={userInfo?.name}
/>
<ComposeContainerBlank
sx={{
height: "auto",
}}
>
{selectedGroup && !currentThread && (
<ComposeContainer
onClick={() => {
setIsOpenFilterList(true);
}}
ref={anchorElInstanceFilter}
>
<ComposeIcon src={SortSVG} />
<SelectInstanceContainerFilterInner>
<ComposeP>Sort by</ComposeP>
<ArrowDownIcon src={ArrowDownSVG} />
</SelectInstanceContainerFilterInner>
</ComposeContainer>
)}
</ComposeContainerBlank>
</Box>
<Spacer height="30px" />
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<AllThreadP>{filterMode}</AllThreadP>
<RefreshIcon onClick={refetchThreadsLists} sx={{
color: 'white',
cursor: 'pointer'
}} />
</Box>
<Spacer height="30px" />
{combinedListTempAndReal.map((thread) => {
const hasViewedRecent =
viewedThreads[
`qmail_threads_${thread?.threadData?.groupId}_${thread?.threadId}`
];
const shouldAppearLighter =
hasViewedRecent &&
filterMode === "Recently active" &&
thread?.threadData?.createdAt < hasViewedRecent?.timestamp;
return (
<SingleThreadParent
onClick={() => {
setCurrentThread(thread);
if(thread?.threadId && thread?.threadData?.name){
updateThreadActivity({
threadId: thread?.threadId, qortalName: thread?.threadData?.name, groupId: groupId, thread: thread
})
}
}}
>
<Avatar
sx={{
height: "50px",
width: "50px",
}}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${thread?.threadData?.name}/qortal_avatar?async=true`}
alt={thread?.threadData?.name}
>
{thread?.threadData?.name?.charAt(0)}
</Avatar>
<ThreadInfoColumn>
<ThreadInfoColumnNameP>
<ThreadInfoColumnbyP>by </ThreadInfoColumnbyP>
{thread?.threadData?.name}
</ThreadInfoColumnNameP>
<ThreadInfoColumnTime>
{formatTimestamp(thread?.threadData?.createdAt)}
</ThreadInfoColumnTime>
</ThreadInfoColumn>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
<ThreadSingleTitle
sx={{
fontWeight: shouldAppearLighter && 300,
}}
>
{thread?.threadData?.title}
</ThreadSingleTitle>
{filterMode === "Recently active" && (
<div
style={{
display: "flex",
alignItems: "center",
}}
>
<ThreadSingleLastMessageP>
<ThreadSingleLastMessageSpanP>
last message:{" "}
</ThreadSingleLastMessageSpanP>
{formatDate(thread?.created)}
</ThreadSingleLastMessageP>
</div>
)}
</div>
<Box onClick={()=> {
setTimeout(() => {
executeEvent("threadFetchMode", {
mode: "last-page"
});
}, 300);
}} sx={{
position: 'absolute',
bottom: '2px',
right: '2px',
borderRadius: '5px',
backgroundColor: '#27282c',
display: 'flex',
gap: '10px',
alignItems: 'center',
padding: '5px',
cursor: 'pointer',
'&:hover': {
background: 'rgba(255, 255, 255, 0.60)'
}
}}>
<Typography sx={{
color: 'white',
fontSize: '12px'
}}>Last page</Typography>
<ArrowForwardIosIcon sx={{
color: 'white',
fontSize: '12px'
}} />
</Box>
</SingleThreadParent>
);
})}
<Box
sx={{
width: "100%",
justifyContent: "center",
}}
>
{listOfThreadsToDisplay.length >= 20 &&
filterMode !== "Recently active" && (
<LazyLoad
onLoadMore={() => getAllThreads(groupId, filterMode, false)}
></LazyLoad>
)}
</Box>
</ThreadContainer>
</ThreadContainerFullWidth>
<LoadingSnackbar
open={isLoading}
info={{
message: "Loading threads... please wait.",
}}
/>
</GroupContainer>
);
};

View File

@@ -0,0 +1,799 @@
import {
AppBar,
Button,
Toolbar,
Typography,
Box,
TextField,
} from "@mui/material";
import { styled } from "@mui/system";
export const InstanceContainer = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
width: "100%",
backgroundColor: "var(--color-instance)",
height: "59px",
flexShrink: 0,
justifyContent: "space-between",
}));
export const MailContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
width: "100%",
height: "calc(100vh - 78px)",
overflow: "hidden",
}));
export const MailBody = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "row",
width: "100%",
height: "calc(100% - 59px)",
// overflow: 'auto !important'
}));
export const MailBodyInner = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
width: "50%",
height: "100%",
}));
export const MailBodyInnerHeader = styled(Box)(({ theme }) => ({
display: "flex",
width: "100%",
height: "25px",
marginTop: "50px",
marginBottom: "35px",
justifyContent: "center",
alignItems: "center",
gap: "11px",
}));
export const MailBodyInnerScroll = styled(Box)`
display: flex;
flex-direction: column;
overflow: auto !important;
transition: background-color 0.3s;
height: calc(100% - 110px);
&::-webkit-scrollbar {
width: 8px;
height: 8px;
background-color: transparent; /* Initially transparent */
transition: background-color 0.3s; /* Transition for background color */
}
&::-webkit-scrollbar-thumb {
background-color: transparent; /* Initially transparent */
border-radius: 3px; /* Scrollbar thumb radius */
transition: background-color 0.3s; /* Transition for thumb color */
}
&:hover {
&::-webkit-scrollbar {
background-color: #494747; /* Scrollbar background color on hover */
}
&::-webkit-scrollbar-thumb {
background-color: #ffffff3d; /* Scrollbar thumb color on hover */
}
&::-webkit-scrollbar-thumb:hover {
background-color: #ffffff3d; /* Color when hovering over the thumb */
}
}
`;
export const ComposeContainer = styled(Box)(({ theme }) => ({
display: "flex",
width: "150px",
alignItems: "center",
gap: "7px",
height: "100%",
cursor: "pointer",
transition: "0.2s background-color",
justifyContent: "center",
"&:hover": {
backgroundColor: "rgba(67, 68, 72, 1)",
},
}));
export const ComposeContainerBlank = styled(Box)(({ theme }) => ({
display: "flex",
width: "150px",
alignItems: "center",
gap: "7px",
height: "100%",
}));
export const ComposeP = styled(Typography)(({ theme }) => ({
fontSize: "15px",
fontWeight: 500,
}));
export const ComposeIcon = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
cursor: "pointer",
});
export const ArrowDownIcon = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
cursor: "pointer",
});
export const MailIconImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
});
export const MailMessageRowInfoImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
});
export const SelectInstanceContainer = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "17px",
}));
export const SelectInstanceContainerInner = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "3px",
cursor: "pointer",
padding: "8px",
transition: "all 0.2s",
"&:hover": {
borderRadius: "8px",
background: "#434448",
},
}));
export const SelectInstanceContainerFilterInner = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "3px",
cursor: "pointer",
padding: "8px",
transition: "all 0.2s"
}));
export const InstanceLabel = styled(Typography)(({ theme }) => ({
fontSize: "16px",
fontWeight: 500,
color: "#FFFFFF33",
}));
export const InstanceP = styled(Typography)(({ theme }) => ({
fontSize: "16px",
fontWeight: 500,
}));
export const MailMessageRowContainer = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
cursor: "pointer",
justifyContent: "space-between",
borderRadius: "56px 5px 10px 56px",
paddingRight: "15px",
transition: "background 0.2s",
gap: "10px",
"&:hover": {
background: "#434448",
},
}));
export const MailMessageRowProfile = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
cursor: "pointer",
justifyContent: "flex-start",
gap: "10px",
width: "50%",
overflow: "hidden",
}));
export const MailMessageRowInfo = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
cursor: "pointer",
justifyContent: "flex-start",
gap: "7px",
width: "50%",
}));
export const MailMessageRowInfoStatusNotDecrypted = styled(Typography)(
({ theme }) => ({
fontSize: "16px",
fontWeight: 900,
textTransform: "uppercase",
paddingTop: "2px",
})
);
export const MailMessageRowInfoStatusRead = styled(Typography)(({ theme }) => ({
fontSize: "16px",
fontWeight: 300,
}));
export const MessageExtraInfo = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
gap: "2px",
overflow: "hidden",
}));
export const MessageExtraName = styled(Typography)(({ theme }) => ({
fontSize: "16px",
fontWeight: 900,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
}));
export const MessageExtraDate = styled(Typography)(({ theme }) => ({
fontSize: "15px",
fontWeight: 500,
}));
export const MessagesContainer = styled(Box)(({ theme }) => ({
width: "460px",
maxWidth: "90%",
display: "flex",
flexDirection: "column",
gap: "12px",
}));
export const InstanceListParent = styled(Box)`
display: flex;
flex-direction: column;
width: 100%;
min-height: 246px;
max-height: 325px;
width: 425px;
padding: 10px 0px 7px 0px;
background-color: var(--color-instance-popover-bg);
border: 1px solid rgba(0, 0, 0, 0.1);
`;
export const InstanceListHeader = styled(Box)`
display: flex;
flex-direction: column;
width: 100%;
background-color: var(--color-instance-popover-bg);
`;
export const InstanceFooter = styled(Box)`
display: flex;
flex-direction: column;
width: 100%;
flex-shrink: 0;
`;
export const InstanceListContainer = styled(Box)`
width: 100%;
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: auto !important;
transition: background-color 0.3s;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
background-color: transparent; /* Initially transparent */
transition: background-color 0.3s; /* Transition for background color */
}
&::-webkit-scrollbar-thumb {
background-color: transparent; /* Initially transparent */
border-radius: 3px; /* Scrollbar thumb radius */
transition: background-color 0.3s; /* Transition for thumb color */
}
&:hover {
&::-webkit-scrollbar {
background-color: #494747; /* Scrollbar background color on hover */
}
&::-webkit-scrollbar-thumb {
background-color: #ffffff3d; /* Scrollbar thumb color on hover */
}
&::-webkit-scrollbar-thumb:hover {
background-color: #ffffff3d; /* Color when hovering over the thumb */
}
}
`;
export const InstanceListContainerRowLabelContainer = styled(Box)(
({ theme }) => ({
width: "100%",
display: "flex",
alignItems: "center",
gap: "10px",
height: "50px",
})
);
export const InstanceListContainerRow = styled(Box)(({ theme }) => ({
width: "100%",
display: "flex",
alignItems: "center",
gap: "10px",
height: "50px",
cursor: "pointer",
transition: "0.2s background",
"&:hover": {
background: "rgba(67, 68, 72, 1)",
},
flexShrink: 0,
}));
export const InstanceListContainerRowCheck = styled(Box)(({ theme }) => ({
width: "47px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}));
export const InstanceListContainerRowMain = styled(Box)(({ theme }) => ({
display: "flex",
justifyContent: "space-between",
width: "100%",
alignItems: "center",
paddingRight: "30px",
overflow: "hidden",
}));
export const CloseParent = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "20px",
}));
export const InstanceListContainerRowMainP = styled(Typography)(
({ theme }) => ({
fontWeight: 500,
fontSize: "16px",
textOverflow: "ellipsis",
overflow: "hidden",
})
);
export const InstanceListContainerRowCheckIcon = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
});
export const InstanceListContainerRowGroupIcon = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
});
export const TypeInAliasTextfield = styled(TextField)({
width: "340px", // Adjust the width as needed
borderRadius: "5px",
backgroundColor: "rgba(30, 30, 32, 1)",
border: "none",
outline: "none",
input: {
fontSize: 16,
color: "white",
"&::placeholder": {
fontSize: 16,
color: "rgba(255, 255, 255, 0.2)",
},
border: "none",
outline: "none",
padding: "10px",
},
"& .MuiOutlinedInput-root": {
"& fieldset": {
border: "none",
},
"&:hover fieldset": {
border: "none",
},
"&.Mui-focused fieldset": {
border: "none",
},
},
"& .MuiInput-underline:before": {
borderBottom: "none",
},
"& .MuiInput-underline:hover:not(.Mui-disabled):before": {
borderBottom: "none",
},
"& .MuiInput-underline:after": {
borderBottom: "none",
},
});
export const NewMessageCloseImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
cursor: "pointer",
});
export const NewMessageHeaderP = styled(Typography)(({ theme }) => ({
fontSize: "18px",
fontWeight: 600,
}));
export const NewMessageInputRow = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: "3px solid rgba(237, 239, 241, 1)",
width: "100%",
paddingBottom: "6px",
}));
export const NewMessageInputLabelP = styled(Typography)`
color: rgba(84, 84, 84, 0.7);
font-size: 20px;
font-style: normal;
font-weight: 400;
line-height: 120%; /* 24px */
letter-spacing: 0.15px;
`;
export const AliasLabelP = styled(Typography)`
color: rgba(84, 84, 84, 0.7);
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 120%; /* 24px */
letter-spacing: 0.15px;
transition: color 0.2s;
cursor: pointer;
&:hover {
color: rgba(43, 43, 43, 1);
}
`;
export const NewMessageAliasContainer = styled(Box)(({ theme }) => ({
display: "flex",
alignItems: "center",
gap: "12px",
}));
export const AttachmentContainer = styled(Box)(({ theme }) => ({
height: "36px",
width: "100%",
display: "flex",
alignItems: "center",
}));
export const NewMessageAttachmentImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
cursor: "pointer",
padding: "10px",
border: "1px dashed #646464",
});
export const NewMessageSendButton = styled(Box)`
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.9);
display: inline-flex;
padding: 8px 16px 8px 12px;
justify-content: center;
align-items: center;
gap: 8px;
width: fit-content;
transition: all 0.2s;
color: black;
min-width: 120px;
gap: 8px;
position: relative;
cursor: pointer;
&:hover {
background-color: rgba(41, 41, 43, 1);
color: white;
svg path {
fill: white; // Fill color changes to white on hover
}
}
`;
export const NewMessageSendP = styled(Typography)`
font-family: Roboto;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 120%; /* 19.2px */
letter-spacing: -0.16px;
`;
export const ShowMessageNameP = styled(Typography)`
font-family: Roboto;
font-size: 16px;
font-weight: 900;
line-height: 19px;
letter-spacing: 0em;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
export const ShowMessageTimeP = styled(Typography)`
color: rgba(255, 255, 255, 0.5);
font-family: Roboto;
font-size: 15px;
font-style: normal;
font-weight: 500;
line-height: normal;
`;
export const ShowMessageSubjectP = styled(Typography)`
font-family: Roboto;
font-size: 16px;
font-weight: 500;
line-height: 19px;
letter-spacing: 0.0075em;
text-align: left;
`;
export const ShowMessageButton = styled(Box)`
display: inline-flex;
padding: 8px 16px 8px 16px;
align-items: center;
justify-content: center;
gap: 8px;
width: fit-content;
transition: all 0.2s;
color: white;
background-color: rgba(41, 41, 43, 1)
min-width: 120px;
gap: 8px;
border-radius: 4px;
border: 0.5px solid rgba(255, 255, 255, 0.70);
font-family: Roboto;
min-width: 120px;
cursor: pointer;
&:hover {
border-radius: 4px;
border: 0.5px solid rgba(255, 255, 255, 0.70);
background: #434448;
}
`;
export const ShowMessageReturnButton = styled(Box)`
display: inline-flex;
padding: 8px 16px 8px 16px;
align-items: center;
justify-content: center;
gap: 8px;
width: fit-content;
transition: all 0.2s;
color: white;
background-color: rgba(41, 41, 43, 1)
min-width: 120px;
gap: 8px;
border-radius: 4px;
font-family: Roboto;
min-width: 120px;
cursor: pointer;
&:hover {
border-radius: 4px;
background: #434448;
}
`;
export const ShowMessageButtonP = styled(Typography)`
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 120%; /* 19.2px */
letter-spacing: -0.16px;
color: white;
`;
export const ShowMessageButtonImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
cursor: "pointer",
});
export const MailAttachmentImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
});
export const AliasAvatarImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
});
export const MoreImg = styled("img")({
width: "auto",
height: "auto",
userSelect: "none",
objectFit: "contain",
transition: "0.2s all",
"&:hover": {
transform: "scale(1.3)",
},
});
export const MoreP = styled(Typography)`
color: rgba(255, 255, 255, 0.5);
/* Attachments */
font-family: Roboto;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 120%; /* 19.2px */
letter-spacing: -0.16px;
white-space: nowrap;
`;
export const ThreadContainerFullWidth = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
width: "100%",
alignItems: "center",
}));
export const ThreadContainer = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
width: "100%",
maxWidth: "95%",
}));
export const GroupNameP = styled(Typography)`
color: #fff;
font-size: 25px;
font-style: normal;
font-weight: 700;
line-height: 120%; /* 30px */
letter-spacing: 0.188px;
`;
export const AllThreadP = styled(Typography)`
color: #FFF;
font-size: 20px;
font-style: normal;
font-weight: 400;
line-height: 120%; /* 24px */
letter-spacing: 0.15px;
`;
export const SingleThreadParent = styled(Box)`
border-radius: 35px 4px 4px 35px;
position: relative;
background: #434448;
display: flex;
padding: 13px;
cursor: pointer;
margin-bottom: 5px;
height: 76px;
align-items:center;
transition: 0.2s all;
&:hover {
background: rgba(255, 255, 255, 0.20)
}
`;
export const SingleTheadMessageParent = styled(Box)`
border-radius: 35px 4px 4px 35px;
background: #434448;
display: flex;
padding: 13px;
cursor: pointer;
margin-bottom: 5px;
height: 76px;
align-items:center;
`;
export const ThreadInfoColumn = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
width: "170px",
gap: '2px',
marginLeft: '10px',
height: '100%',
justifyContent: 'center'
}));
export const ThreadInfoColumnNameP = styled(Typography)`
color: #FFF;
font-family: Roboto;
font-size: 16px;
font-style: normal;
font-weight: 900;
line-height: normal;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
export const ThreadInfoColumnbyP = styled('span')`
color: rgba(255, 255, 255, 0.80);
font-family: Roboto;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
`;
export const ThreadInfoColumnTime = styled(Typography)`
color: rgba(255, 255, 255, 0.80);
font-family: Roboto;
font-size: 15px;
font-style: normal;
font-weight: 500;
line-height: normal;
`
export const ThreadSingleTitle = styled(Typography)`
color: #FFF;
font-family: Roboto;
font-size: 23px;
font-style: normal;
font-weight: 700;
line-height: normal;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`
export const ThreadSingleLastMessageP = styled(Typography)`
color: #FFF;
font-family: Roboto;
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: normal;
`
export const ThreadSingleLastMessageSpanP = styled('span')`
color: #FFF;
font-family: Roboto;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
`;
export const GroupContainer = styled(Box)`
position: relative;
overflow: auto;
width: 100%;
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-track:hover {
background-color: transparent;
}
&::-webkit-scrollbar {
width: 16px;
height: 10px;
background-color: white;
}
&::-webkit-scrollbar-thumb {
background-color: #838eee;
border-radius: 8px;
background-clip: content-box;
border: 4px solid transparent;
}
&::-webkit-scrollbar-thumb:hover {
background-color: #6270f0;
}
`
export const CloseContainer = styled(Box)(({ theme }) => ({
display: "flex",
width: "50px",
overflow: "hidden",
alignItems: "center",
cursor: "pointer",
transition: "0.2s background-color",
justifyContent: "center",
position: 'absolute',
top: '0px',
right: '0px',
height: '50px',
borderRadius: '0px 12px 0px 0px',
"&:hover": {
backgroundColor: "rgba(162, 31, 31, 1)",
},
}));

View File

@@ -0,0 +1,554 @@
import React, { useEffect, useRef, useState } from "react";
import { Box, Button, CircularProgress, Input, Typography } from "@mui/material";
import ShortUniqueId from "short-unique-id";
import CloseIcon from "@mui/icons-material/Close";
import ModalCloseSVG from "../../../assets/svgs/ModalClose.svg";
import ComposeIconSVG from "../../../assets/svgs/ComposeIcon.svg";
import {
AttachmentContainer,
CloseContainer,
ComposeContainer,
ComposeIcon,
ComposeP,
InstanceFooter,
InstanceListContainer,
InstanceListHeader,
NewMessageAttachmentImg,
NewMessageCloseImg,
NewMessageHeaderP,
NewMessageInputRow,
NewMessageSendButton,
NewMessageSendP,
} from "./Mail-styles";
import { ReusableModal } from "./ReusableModal";
import { Spacer } from "../../../common/Spacer";
import { formatBytes } from "../../../utils/Size";
import { CreateThreadIcon } from "../../../assets/svgs/CreateThreadIcon";
import { SendNewMessage } from "../../../assets/svgs/SendNewMessage";
import { TextEditor } from "./TextEditor";
import { MyContext, pauseAllQueues, resumeAllQueues } from "../../../App";
import { getFee } from "../../../background";
import TipTap from "../../Chat/TipTap";
import { MessageDisplay } from "../../Chat/MessageDisplay";
import { CustomizedSnackbars } from "../../Snackbar/Snackbar";
import { saveTempPublish } from "../../Chat/GroupAnnouncements";
const uid = new ShortUniqueId({ length: 8 });
export const toBase64 = (file: File): Promise<string | ArrayBuffer | null> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => {
reject(error);
};
});
export function objectToBase64(obj: any) {
// Step 1: Convert the object to a JSON string
const jsonString = JSON.stringify(obj);
// Step 2: Create a Blob from the JSON string
const blob = new Blob([jsonString], { type: "application/json" });
// Step 3: Create a FileReader to read the Blob as a base64-encoded string
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result === "string") {
// Remove 'data:application/json;base64,' prefix
const base64 = reader.result.replace(
"data:application/json;base64,",
""
);
resolve(base64);
} else {
reject(new Error("Failed to read the Blob as a base64-encoded string"));
}
};
reader.onerror = () => {
reject(reader.error);
};
reader.readAsDataURL(blob);
});
}
interface NewMessageProps {
hideButton?: boolean;
groupInfo: any;
currentThread?: any;
isMessage?: boolean;
messageCallback?: (val: any) => void;
publishCallback?: () => void;
refreshLatestThreads?: () => void;
members: any;
}
export const publishGroupEncryptedResource = async ({
encryptedData,
identifier,
}) => {
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "publishGroupEncryptedResource",
payload: {
encryptedData,
identifier,
},
},
(response) => {
if (!response?.error) {
res(response);
return
}
rej(response.error);
}
);
});
};
export const encryptSingleFunc = 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) {}
};
export const NewThread = ({
groupInfo,
members,
currentThread,
isMessage = false,
publishCallback,
userInfo,
getSecretKey,
closeCallback,
postReply,
myName
}: NewMessageProps) => {
const { show } = React.useContext(MyContext);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [value, setValue] = useState("");
const [isSending, setIsSending] = useState(false);
const [threadTitle, setThreadTitle] = useState<string>("");
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const editorRef = useRef(null);
const setEditorRef = (editorInstance) => {
editorRef.current = editorInstance;
};
useEffect(() => {
if (postReply) {
setIsOpen(true);
}
}, [postReply]);
const closeModal = () => {
setIsOpen(false);
setValue("");
};
async function publishQDNResource() {
try {
pauseAllQueues()
if(isSending) return
setIsSending(true)
let name: string = "";
let errorMsg = "";
name = userInfo?.name || "";
const missingFields: string[] = [];
if (!isMessage && !threadTitle) {
errorMsg = "Please provide a thread title";
}
if (!name) {
errorMsg = "Cannot send a message without a access to your name";
}
if (!groupInfo) {
errorMsg = "Cannot access group information";
}
// if (!description) missingFields.push('subject')
if (missingFields.length > 0) {
const missingFieldsString = missingFields.join(", ");
const errMsg = `Missing: ${missingFieldsString}`;
errorMsg = errMsg;
}
if (errorMsg) {
// dispatch(
// setNotification({
// msg: errorMsg,
// alertType: "error",
// })
// );
throw new Error(errorMsg);
}
const htmlContent = editorRef.current.getHTML();
if (!htmlContent?.trim() || htmlContent?.trim() === "<p></p>")
throw new Error("Please provide a first message to the thread");
const fee = await getFee("ARBITRARY");
let feeToShow = fee.fee;
if (!isMessage) {
feeToShow = +feeToShow * 2;
}
await show({
message: "Would you like to perform a ARBITRARY transaction?",
publishFee: feeToShow + " QORT",
});
let reply = null;
if (postReply) {
reply = { ...postReply };
if (reply.reply) {
delete reply.reply;
}
}
const mailObject: any = {
createdAt: Date.now(),
version: 1,
textContentV2: htmlContent,
name,
threadOwner: currentThread?.threadData?.name || name,
reply,
};
const secretKey = await getSecretKey();
if (!secretKey) {
throw new Error("Cannot get group secret key");
}
if (!isMessage) {
const idThread = uid.rnd();
const idMsg = uid.rnd();
const messageToBase64 = await objectToBase64(mailObject);
const encryptSingleFirstPost = await encryptSingleFunc(
messageToBase64,
secretKey
);
const threadObject = {
title: threadTitle,
groupId: groupInfo.id,
createdAt: Date.now(),
name,
};
const threadToBase64 = await objectToBase64(threadObject);
const encryptSingleThread = await encryptSingleFunc(
threadToBase64,
secretKey
);
let identifierThread = `grp-${groupInfo.groupId}-thread-${idThread}`;
await publishGroupEncryptedResource({
identifier: identifierThread,
encryptedData: encryptSingleThread,
});
let identifierPost = `thmsg-${identifierThread}-${idMsg}`;
await publishGroupEncryptedResource({
identifier: identifierPost,
encryptedData: encryptSingleFirstPost,
});
const dataToSaveToStorage = {
name: myName,
identifier: identifierThread,
service: 'DOCUMENT',
tempData: threadObject,
created: Date.now(),
}
const dataToSaveToStoragePost = {
name: myName,
identifier: identifierPost,
service: 'DOCUMENT',
tempData: mailObject,
created: Date.now(),
threadId: identifierThread
}
await saveTempPublish({data: dataToSaveToStorage, key: 'thread'})
await saveTempPublish({data: dataToSaveToStoragePost, key: 'thread-post'})
setInfoSnack({
type: "success",
message: "Successfully created thread. It may take some time for the publish to propagate",
});
setOpenSnack(true)
// dispatch(
// setNotification({
// msg: "Message sent",
// alertType: "success",
// })
// );
if (publishCallback) {
publishCallback()
// threadCallback({
// threadData: threadObject,
// threadOwner: name,
// name,
// threadId: identifierThread,
// created: Date.now(),
// service: 'MAIL_PRIVATE',
// identifier: identifier
// })
}
closeModal();
} else {
if (!currentThread) throw new Error("unable to locate thread Id");
const idThread = currentThread.threadId;
const messageToBase64 = await objectToBase64(mailObject);
const encryptSinglePost = await encryptSingleFunc(
messageToBase64,
secretKey
);
const idMsg = uid.rnd();
let identifier = `thmsg-${idThread}-${idMsg}`;
const res = await publishGroupEncryptedResource({
identifier: identifier,
encryptedData: encryptSinglePost,
});
const dataToSaveToStoragePost = {
threadId: idThread,
name: myName,
identifier: identifier,
service: 'DOCUMENT',
tempData: mailObject,
created: Date.now()
}
await saveTempPublish({data: dataToSaveToStoragePost, key: 'thread-post'})
// await qortalRequest(multiplePublishMsg);
// dispatch(
// setNotification({
// msg: "Message sent",
// alertType: "success",
// })
// );
setInfoSnack({
type: "success",
message: "Successfully created post. It may take some time for the publish to propagate",
});
setOpenSnack(true)
if(publishCallback){
publishCallback()
}
// messageCallback({
// identifier,
// id: identifier,
// name,
// service: MAIL_SERVICE_TYPE,
// created: Date.now(),
// ...mailObject,
// });
}
closeModal();
} catch (error: any) {
if(error?.message){
setInfoSnack({
type: "error",
message: error?.message,
});
setOpenSnack(true)
}
} finally {
setIsSending(false);
resumeAllQueues()
}
}
const sendMail = () => {
publishQDNResource();
};
return (
<Box
sx={{
display: "flex",
}}
>
<ComposeContainer
sx={{
padding: "15px",
}}
onClick={() => setIsOpen(true)}
>
<ComposeIcon src={ComposeIconSVG} />
<ComposeP>{currentThread ? "New Post" : "New Thread"}</ComposeP>
</ComposeContainer>
<ReusableModal
open={isOpen}
customStyles={{
maxHeight: "95vh",
maxWidth: "950px",
height: "700px",
borderRadius: "12px 12px 0px 0px",
background: "#434448",
padding: "0px",
gap: "0px",
}}
>
<InstanceListHeader
sx={{
height: "50px",
padding: "20px 42px",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: "#434448",
}}
>
<NewMessageHeaderP>
{isMessage ? "Post Message" : "New Thread"}
</NewMessageHeaderP>
<CloseContainer onClick={closeModal}>
<NewMessageCloseImg src={ModalCloseSVG} />
</CloseContainer>
</InstanceListHeader>
<InstanceListContainer
sx={{
backgroundColor: "#434448",
padding: "20px 42px",
height: "calc(100% - 150px)",
flexShrink: 0,
}}
>
{!isMessage && (
<>
<Spacer height="10px" />
<NewMessageInputRow>
<Input
id="standard-adornment-name"
value={threadTitle}
onChange={(e) => {
setThreadTitle(e.target.value);
}}
placeholder="Thread Title"
disableUnderline
autoComplete="off"
autoCorrect="off"
sx={{
width: "100%",
color: "white",
"& .MuiInput-input::placeholder": {
color: "rgba(255,255,255, 0.70) !important",
fontSize: "20px",
fontStyle: "normal",
fontWeight: 400,
lineHeight: "120%", // 24px
letterSpacing: "0.15px",
opacity: 1,
},
"&:focus": {
outline: "none",
},
// Add any additional styles for the input here
}}
/>
</NewMessageInputRow>
</>
)}
{postReply && postReply.textContentV2 && (
<Box
sx={{
width: "100%",
maxHeight: "120px",
overflow: "auto",
}}
>
<MessageDisplay htmlContent={postReply?.textContentV2} />
</Box>
)}
<Spacer height="30px" />
<Box
sx={{
maxHeight: "40vh",
}}
>
<TipTap
setEditorRef={setEditorRef}
onEnter={sendMail}
disableEnter
/>
{/* <TextEditor
inlineContent={value}
setInlineContent={(val: any) => {
setValue(val);
}}
/> */}
</Box>
</InstanceListContainer>
<InstanceFooter
sx={{
backgroundColor: "#434448",
padding: "20px 42px",
alignItems: "center",
height: "90px",
}}
>
<NewMessageSendButton onClick={sendMail}>
{isSending && (
<Box sx={{height: '100%', position: 'absolute', width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center'}}>
<CircularProgress sx={{
}} size={'12px'} />
</Box>
)}
<NewMessageSendP>
{isMessage ? "Post" : "Create Thread"}
</NewMessageSendP>
{isMessage ? (
<SendNewMessage
color="red"
opacity={1}
height="25px"
width="25px"
/>
) : (
<CreateThreadIcon
color="red"
opacity={1}
height="25px"
width="25px"
/>
)}
</NewMessageSendButton>
</InstanceFooter>
</ReusableModal>
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
</Box>
);
};

View File

@@ -0,0 +1,129 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createEditor} from 'slate';
import { withReact, Slate, Editable, RenderElementProps, RenderLeafProps } from 'slate-react';
type ExtendedRenderElementProps = RenderElementProps & { mode?: string }
export const renderElement = ({
attributes,
children,
element,
mode
}: ExtendedRenderElementProps) => {
switch (element.type) {
case 'block-quote':
return <blockquote {...attributes}>{children}</blockquote>
case 'heading-2':
return (
<h2
className="h2"
{...attributes}
style={{ textAlign: element.textAlign }}
>
{children}
</h2>
)
case 'heading-3':
return (
<h3
className="h3"
{...attributes}
style={{ textAlign: element.textAlign }}
>
{children}
</h3>
)
case 'code-block':
return (
<pre {...attributes} className="code-block">
<code>{children}</code>
</pre>
)
case 'code-line':
return <div {...attributes}>{children}</div>
case 'link':
return (
<a href={element.url} {...attributes}>
{children}
</a>
)
default:
return (
<p
className={`paragraph${mode ? `-${mode}` : ''}`}
{...attributes}
style={{ textAlign: element.textAlign }}
>
{children}
</p>
)
}
}
export const renderLeaf = ({ attributes, children, leaf }: RenderLeafProps) => {
let el = children
if (leaf.bold) {
el = <strong>{el}</strong>
}
if (leaf.italic) {
el = <em>{el}</em>
}
if (leaf.underline) {
el = <u>{el}</u>
}
if (leaf.link) {
el = (
<a href={leaf.link} {...attributes}>
{el}
</a>
)
}
return <span {...attributes}>{el}</span>
}
interface ReadOnlySlateProps {
content: any
mode?: string
}
const ReadOnlySlate: React.FC<ReadOnlySlateProps> = ({ content, mode }) => {
const [load, setLoad] = useState(false)
const editor = useMemo(() => withReact(createEditor()), [])
const value = useMemo(() => content, [content])
const performUpdate = useCallback(async()=> {
setLoad(true)
await new Promise<void>((res)=> {
setTimeout(() => {
res()
}, 250);
})
setLoad(false)
}, [])
useEffect(()=> {
performUpdate()
}, [value])
if(load) return null
return (
<Slate editor={editor} value={value} onChange={() => {}}>
<Editable
readOnly
renderElement={(props) => renderElement({ ...props, mode })}
renderLeaf={renderLeaf}
/>
</Slate>
)
}
export default ReadOnlySlate;

View File

@@ -0,0 +1,57 @@
import React from 'react'
import { Box, Modal, useTheme } from '@mui/material'
interface MyModalProps {
open: boolean
onClose?: () => void
onSubmit?: (obj: any) => Promise<void>
children: any
customStyles?: any
}
export const ReusableModal: React.FC<MyModalProps> = ({
open,
onClose,
onSubmit,
children,
customStyles = {}
}) => {
const theme = useTheme()
return (
<Modal
open={open}
onClose={onClose}
aria-labelledby="modal-title"
aria-describedby="modal-description"
componentsProps={{
backdrop: {
style: {
backdropFilter: 'blur(3px)',
},
},
}}
disableAutoFocus
disableEnforceFocus
disableRestoreFocus
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '75%',
bgcolor: theme.palette.primary.main,
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
gap: 2,
...customStyles
}}
>
{children}
</Box>
</Modal>
)
}

View File

@@ -0,0 +1,224 @@
import React, { useState } from "react";
import { Avatar, Box, IconButton } from "@mui/material";
import DOMPurify from "dompurify";
import FormatQuoteIcon from '@mui/icons-material/FormatQuote';
import MoreSVG from '../../../assets/svgs/More.svg'
import {
MoreImg,
MoreP,
SingleTheadMessageParent,
ThreadInfoColumn,
ThreadInfoColumnNameP,
ThreadInfoColumnTime,
} from "./Mail-styles";
import { Spacer } from "../../../common/Spacer";
import { DisplayHtml } from "./DisplayHtml";
import { formatTimestampForum } from "../../../utils/time";
import ReadOnlySlate from "./ReadOnlySlate";
import { MessageDisplay } from "../../Chat/MessageDisplay";
import { getBaseApi } from "../../../background";
import { getBaseApiReact } from "../../../App";
export const ShowMessage = ({ message, openNewPostWithQuote }: any) => {
const [expandAttachments, setExpandAttachments] = useState<boolean>(false);
let cleanHTML = "";
if (message?.htmlContent) {
cleanHTML = DOMPurify.sanitize(message.htmlContent);
}
return (
<SingleTheadMessageParent
sx={{
height: "auto",
alignItems: "flex-start",
cursor: "default",
borderRadius: '35px 4px 4px 4px'
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
width: '100%'
}}
>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
gap: "10px",
}}
>
<Avatar sx={{
height: '50px',
width: '50px'
}} src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${message?.name}/qortal_avatar?async=true`} alt={message?.name}>{message?.name?.charAt(0)}</Avatar>
<ThreadInfoColumn>
<ThreadInfoColumnNameP>{message?.name}</ThreadInfoColumnNameP>
<ThreadInfoColumnTime>
{formatTimestampForum(message?.created)}
</ThreadInfoColumnTime>
</ThreadInfoColumn>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
{message?.attachments?.length > 0 && (
<Box
sx={{
width: "100%",
marginTop: "10px",
}}
>
{message?.attachments
.map((file: any, index: number) => {
const isFirst = index === 0
return (
<Box
sx={{
display: expandAttachments ? "flex" : !expandAttachments && isFirst ? 'flex' : 'none',
alignItems: "center",
justifyContent: "flex-start",
width: "100%",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "5px",
cursor: "pointer",
width: "auto",
}}
>
{/* <FileElement
fileInfo={{ ...file, mimeTypeSaved: file?.type }}
title={file?.filename}
mode="mail"
otherUser={message?.user}
>
<MailAttachmentImg src={AttachmentMailSVG} />
<Typography
sx={{
fontSize: "16px",
transition: '0.2s all',
"&:hover": {
color: 'rgba(255, 255, 255, 0.90)',
textDecoration: 'underline'
}
}}
>
{file?.originalFilename || file?.filename}
</Typography>
</FileElement> */}
{message?.attachments?.length > 1 && isFirst && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "5px",
}}
onClick={() => {
setExpandAttachments(prev => !prev);
}}
>
<MoreImg
sx={{
marginLeft: "5px",
transform: expandAttachments
? "rotate(180deg)"
: "unset",
}}
src={MoreSVG}
/>
<MoreP>
{expandAttachments ? 'hide' : `(${message?.attachments?.length - 1} more)`}
</MoreP>
</Box>
)}
</Box>
</Box>
);
})
}
</Box>
)}
</div>
</Box>
<Spacer height="20px" />
{message?.reply?.textContentV2 && (
<>
<Box sx={{
width: '100%',
opacity: 0.7,
borderRadius: '5px',
border: '1px solid gray',
boxSizing: 'border-box',
padding: '5px'
}}>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
gap: "10px",
}}
>
<Avatar sx={{
height: '30px',
width: '30px'
}} src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${message?.reply?.name}/qortal_avatar?async=true`} alt={message?.reply?.name}>{message?.reply?.name?.charAt(0)}</Avatar>
<ThreadInfoColumn>
<ThreadInfoColumnNameP sx={{
fontSize: '14px'
}}>{message?.reply?.name}</ThreadInfoColumnNameP>
</ThreadInfoColumn>
</Box>
<MessageDisplay htmlContent={message?.reply?.textContentV2} />
</Box>
<Spacer height="20px" />
</>
)}
{message?.textContent && (
<ReadOnlySlate content={message.textContent} mode="mail" />
)}
{message?.textContentV2 && (
<MessageDisplay htmlContent={message?.textContentV2} />
)}
{message?.htmlContent && (
<div dangerouslySetInnerHTML={{ __html: cleanHTML }} />
)}
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'flex-end'
}}>
<IconButton
onClick={() => openNewPostWithQuote(message)}
>
<FormatQuoteIcon />
</IconButton>
</Box>
</Box>
</SingleTheadMessageParent>
);
};

View File

@@ -0,0 +1,39 @@
import React from "react";
import ReactQuill, { Quill } from "react-quill";
import "react-quill/dist/quill.snow.css";
import ImageResize from "quill-image-resize-module-react";
import './texteditor.css'
Quill.register("modules/imageResize", ImageResize);
const modules = {
imageResize: {
parchment: Quill.import("parchment"),
modules: ["Resize", "DisplaySize"],
},
toolbar: [
["bold", "italic", "underline", "strike"], // styled text
["blockquote", "code-block"], // blocks
[{ header: 1 }, { header: 2 }], // custom button values
[{ list: "ordered" }, { list: "bullet" }], // lists
[{ script: "sub" }, { script: "super" }], // superscript/subscript
[{ indent: "-1" }, { indent: "+1" }], // outdent/indent
[{ direction: "rtl" }], // text direction
[{ size: ["small", false, "large", "huge"] }], // custom dropdown
[{ header: [1, 2, 3, 4, 5, 6, false] }], // custom button values
[{ color: [] }, { background: [] }], // dropdown with defaults
[{ font: [] }], // font family
[{ align: [] }], // text align
["clean"], // remove formatting
// ["image"], // image
],
};
export const TextEditor = ({ inlineContent, setInlineContent }: any) => {
return (
<ReactQuill
theme="snow"
value={inlineContent}
onChange={setInlineContent}
modules={modules}
/>
);
};

View File

@@ -0,0 +1,329 @@
import React, {
FC,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import {
Box,
Skeleton,
} from '@mui/material'
import { ShowMessage } from './ShowMessageWithoutModal'
// import {
// setIsLoadingCustom,
// } from '../../state/features/globalSlice'
import { ComposeP, GroupContainer, GroupNameP, MailIconImg, ShowMessageReturnButton, SingleThreadParent, ThreadContainer, ThreadContainerFullWidth } from './Mail-styles'
import { Spacer } from '../../../common/Spacer'
import { threadIdentifier } from './GroupMail'
import LazyLoad from '../../../common/LazyLoad'
import ReturnSVG from '../../../assets/svgs/Return.svg'
import { NewThread } from './NewThread'
import { decryptPublishes } from '../../Chat/GroupAnnouncements'
import { getBaseApi } from '../../../background'
import { getBaseApiReact } from '../../../App'
interface ThreadProps {
currentThread: any
groupInfo: any
closeThread: () => void
members: any
}
const getEncryptedResource = async ({name, identifier, secretKey})=> {
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];
return messageData.decryptedData
}
export const Thread = ({
currentThread,
groupInfo,
closeThread,
members,
userInfo,
secretKey,
getSecretKey
}: ThreadProps) => {
const [messages, setMessages] = useState<any[]>([])
const [hashMapMailMessages, setHashMapMailMessages] = useState({})
const secretKeyRef = useRef(null)
useEffect(() => {
secretKeyRef.current = secretKey;
}, [secretKey]);
const getIndividualMsg = async (message: any) => {
try {
const responseDataMessage = await getEncryptedResource({identifier: message.identifier, name: message.name, secretKey})
const fullObject = {
...message,
...(responseDataMessage || {}),
id: message.identifier
}
setHashMapMailMessages((prev)=> {
return {
...prev,
[message.identifier]: fullObject
}
})
} catch (error) {}
}
const getMailMessages = React.useCallback(
async (groupInfo: any, reset?: boolean, hideAlert?: boolean) => {
try {
if(!hideAlert){
// dispatch(setIsLoadingCustom('Loading messages'))
}
let threadId = groupInfo.threadId
const offset = messages.length
const identifier = `thmsg-${threadId}`
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&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()
let fullArrayMsg = reset ? [] : [...messages]
let newMessages: any[] = []
for (const message of responseData) {
const index = fullArrayMsg.findIndex(
(p) => p.identifier === message.identifier
)
if (index !== -1) {
fullArrayMsg[index] = message
} else {
fullArrayMsg.push(message)
getIndividualMsg(message)
}
}
setMessages(fullArrayMsg)
} catch (error) {
} finally {
if(!hideAlert){
// dispatch(setIsLoadingCustom(null))
}
}
},
[messages, secretKey]
)
const getMessages = React.useCallback(async () => {
if (!currentThread || !secretKey) return
await getMailMessages(currentThread, true)
}, [getMailMessages, currentThread, secretKey])
const firstMount = useRef(false)
const saveTimestamp = useCallback((currentThread: any, username?: string)=> {
if(!currentThread?.threadData?.groupId || !currentThread?.threadId || !username) return
const threadIdForLocalStorage = `qmail_threads_${currentThread?.threadData?.groupId}_${currentThread?.threadId}`
const threads = JSON.parse(
localStorage.getItem(`qmail_threads_viewedtimestamp_${username}`) || "{}"
);
// Convert to an array of objects with identifier and all fields
let dataArray = Object.entries(threads).map(([identifier, value]) => ({
identifier,
...(value as any),
}));
// Sort the array based on timestamp in descending order
dataArray.sort((a, b) => b.timestamp - a.timestamp);
// Slice the array to keep only the first 500 elements
let latest500 = dataArray.slice(0, 500);
// Convert back to the original object format
let latest500Data: any = {};
latest500.forEach(item => {
const { identifier, ...rest } = item;
latest500Data[identifier] = rest;
});
latest500Data[threadIdForLocalStorage] = {
timestamp: Date.now(),
}
localStorage.setItem(
`qmail_threads_viewedtimestamp_${username}`,
JSON.stringify(latest500Data)
);
}, [])
useEffect(() => {
if (currentThread && secretKey) {
getMessages()
firstMount.current = true
// saveTimestamp(currentThread, user.name)
}
}, [ currentThread, secretKey])
const messageCallback = useCallback((msg: any) => {
// dispatch(addToHashMapMail(msg))
setMessages((prev) => [msg, ...prev])
}, [])
const interval = useRef<any>(null)
const checkNewMessages = React.useCallback(
async (groupInfo: any) => {
try {
let threadId = groupInfo.threadId
const identifier = `thmsg-${threadId}`
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&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 = messages[0]
if (!latestMessage) return
const findMessage = responseData?.findIndex(
(item: any) => item?.identifier === latestMessage?.identifier
)
let sliceLength = responseData.length
if (findMessage !== -1) {
sliceLength = findMessage
}
const newArray = responseData.slice(0, findMessage).reverse()
let fullArrayMsg = [...messages]
for (const message of newArray) {
try {
const responseDataMessage = await getEncryptedResource({identifier: message.identifier, name: message.name, secretKey: secretKeyRef.current})
const fullObject = {
...message,
...(responseDataMessage || {}),
id: message.identifier
}
setHashMapMailMessages((prev)=> {
return {
...prev,
[message.identifier]: fullObject
}
})
const index = messages.findIndex(
(p) => p.identifier === fullObject.identifier
)
if (index !== -1) {
fullArrayMsg[index] = fullObject
} else {
fullArrayMsg.unshift(fullObject)
}
} catch (error) {}
}
setMessages(fullArrayMsg)
} catch (error) {
} finally {
}
},
[messages]
)
const checkNewMessagesFunc = useCallback(() => {
let isCalling = false
interval.current = setInterval(async () => {
if (isCalling) return
isCalling = true
const res = await checkNewMessages(currentThread)
isCalling = false
}, 8000)
}, [checkNewMessages, currentThread])
useEffect(() => {
checkNewMessagesFunc()
return () => {
if (interval?.current) {
clearInterval(interval.current)
}
}
}, [checkNewMessagesFunc])
if (!currentThread) return null
return (
<GroupContainer
sx={{
position: "relative",
overflow: 'auto',
width: '100%'
}}
>
<NewThread
groupInfo={groupInfo}
isMessage={true}
currentThread={currentThread}
messageCallback={messageCallback}
members={members}
userInfo={userInfo}
getSecretKey={getSecretKey}
/>
<ThreadContainerFullWidth>
<ThreadContainer>
<Spacer height="30px" />
<Box sx={{
width: '100%',
alignItems: 'center',
display: 'flex',
justifyContent: 'space-between'
}}>
<GroupNameP>{currentThread?.threadData?.title}</GroupNameP>
<ShowMessageReturnButton onClick={() => {
setMessages([])
closeThread()
}}>
<MailIconImg src={ReturnSVG} />
<ComposeP>Return to Threads</ComposeP>
</ShowMessageReturnButton>
</Box>
<Spacer height="60px" />
{messages.map((message) => {
let fullMessage = message
if (hashMapMailMessages[message?.identifier]) {
fullMessage = hashMapMailMessages[message.identifier]
return <ShowMessage key={message?.identifier} message={fullMessage} />
}
return (
<SingleThreadParent>
<Skeleton
variant="rectangular"
style={{
width: '100%',
height: 60,
borderRadius: '8px',
overflow: 'hidden'
}}
/>
</SingleThreadParent>
)
})}
</ThreadContainer>
</ThreadContainerFullWidth>
{messages.length >= 20 && (
<LazyLoad onLoadMore={()=> getMailMessages(currentThread, false, true)}></LazyLoad>
)}
</GroupContainer>
)
}

View File

@@ -0,0 +1,663 @@
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, IconButton, Skeleton } from "@mui/material";
import { ShowMessage } from "./ShowMessageWithoutModal";
// import {
// setIsLoadingCustom,
// } from '../../state/features/globalSlice'
import {
ComposeP,
GroupContainer,
GroupNameP,
MailIconImg,
ShowMessageReturnButton,
SingleThreadParent,
ThreadContainer,
ThreadContainerFullWidth,
} from "./Mail-styles";
import { Spacer } from "../../../common/Spacer";
import { threadIdentifier } from "./GroupMail";
import LazyLoad from "../../../common/LazyLoad";
import ReturnSVG from "../../../assets/svgs/Return.svg";
import { NewThread } from "./NewThread";
import { decryptPublishes, getTempPublish } from "../../Chat/GroupAnnouncements";
import { LoadingSnackbar } from "../../Snackbar/LoadingSnackbar";
import { subscribeToEvent, unsubscribeFromEvent } from "../../../utils/events";
import RefreshIcon from "@mui/icons-material/Refresh";
import { getBaseApi } from "../../../background";
import { getBaseApiReact } from "../../../App";
interface ThreadProps {
currentThread: any;
groupInfo: any;
closeThread: () => void;
members: any;
}
const getEncryptedResource = async ({ name, identifier, secretKey }) => {
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];
return messageData.decryptedData;
};
export const Thread = ({
currentThread,
groupInfo,
closeThread,
members,
userInfo,
secretKey,
getSecretKey,
updateThreadActivityCurrentThread
}: ThreadProps) => {
const [tempPublishedList, setTempPublishedList] = useState([])
const [messages, setMessages] = useState<any[]>([]);
const [hashMapMailMessages, setHashMapMailMessages] = useState({});
const [hasFirstPage, setHasFirstPage] = useState(false);
const [hasPreviousPage, setHasPreviousPage] = useState(false);
const [hasNextPage, setHasNextPage] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [postReply, setPostReply] = useState(null);
const [hasLastPage, setHasLastPage] = useState(false);
const secretKeyRef = useRef(null);
const currentThreadRef = useRef(null);
const containerRef = useRef(null);
useEffect(() => {
currentThreadRef.current = currentThread;
}, [currentThread]);
useEffect(() => {
secretKeyRef.current = secretKey;
}, [secretKey]);
const getIndividualMsg = async (message: any) => {
try {
const responseDataMessage = await getEncryptedResource({
identifier: message.identifier,
name: message.name,
secretKey,
});
const fullObject = {
...message,
...(responseDataMessage || {}),
id: message.identifier,
};
setHashMapMailMessages((prev) => {
return {
...prev,
[message.identifier]: fullObject,
};
});
} catch (error) {}
};
const setTempData = async ()=> {
try {
let threadId = currentThread.threadId;
const keyTemp = 'thread-post'
const getTempAnnouncements = await getTempPublish()
if(getTempAnnouncements?.[keyTemp]){
let tempData = []
Object.keys(getTempAnnouncements?.[keyTemp] || {}).map((key)=> {
const value = getTempAnnouncements?.[keyTemp][key]
if(value.data?.threadId === threadId){
tempData.push(value.data)
}
})
setTempPublishedList(tempData)
}
} catch (error) {
}
}
const getMailMessages = React.useCallback(
async (groupInfo: any, before, after, isReverse) => {
try {
setTempPublishedList([])
setIsLoading(true);
setHasFirstPage(false);
setHasPreviousPage(false);
setHasLastPage(false);
setHasNextPage(false);
let threadId = groupInfo.threadId;
const identifier = `thmsg-${threadId}`;
let url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=20&includemetadata=false&prefix=true`;
if (!isReverse) {
url = url + "&reverse=false";
}
if (isReverse) {
url = url + "&reverse=true";
}
if (after) {
url = url + `&after=${after}`;
}
if (before) {
url = url + `&before=${before}`;
}
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
let fullArrayMsg = [...responseData];
if (isReverse) {
fullArrayMsg = fullArrayMsg.reverse();
}
// let newMessages: any[] = []
for (const message of responseData) {
getIndividualMsg(message);
}
setMessages(fullArrayMsg);
if (before === null && after === null && isReverse) {
setTimeout(() => {
containerRef.current.scrollIntoView({ behavior: "smooth" });
}, 300);
}
if (fullArrayMsg.length === 0){
setTempData()
return;
}
// check if there are newer posts
const urlNewer = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=1&includemetadata=false&reverse=false&prefix=true&before=${fullArrayMsg[0].created}`;
const responseNewer = await fetch(urlNewer, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataNewer = await responseNewer.json();
if (responseDataNewer.length > 0) {
setHasFirstPage(true);
setHasPreviousPage(true);
} else {
setHasFirstPage(false);
setHasPreviousPage(false);
}
// check if there are older posts
const urlOlder = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&identifier=${identifier}&limit=1&includemetadata=false&reverse=false&prefix=true&after=${
fullArrayMsg[fullArrayMsg.length - 1].created
}`;
const responseOlder = await fetch(urlOlder, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataOlder = await responseOlder.json();
if (responseDataOlder.length > 0) {
setHasLastPage(true);
setHasNextPage(true);
} else {
setHasLastPage(false);
setHasNextPage(false);
setTempData()
updateThreadActivityCurrentThread()
}
} catch (error) {
} finally {
setIsLoading(false);
}
},
[messages, secretKey]
);
const getMessages = React.useCallback(async () => {
if (!currentThread || !secretKey) return;
await getMailMessages(currentThread, null, null, false);
}, [getMailMessages, currentThread, secretKey]);
const firstMount = useRef(false);
const saveTimestamp = useCallback((currentThread: any, username?: string) => {
if (
!currentThread?.threadData?.groupId ||
!currentThread?.threadId ||
!username
)
return;
const threadIdForLocalStorage = `qmail_threads_${currentThread?.threadData?.groupId}_${currentThread?.threadId}`;
const threads = JSON.parse(
localStorage.getItem(`qmail_threads_viewedtimestamp_${username}`) || "{}"
);
// Convert to an array of objects with identifier and all fields
let dataArray = Object.entries(threads).map(([identifier, value]) => ({
identifier,
...(value as any),
}));
// Sort the array based on timestamp in descending order
dataArray.sort((a, b) => b.timestamp - a.timestamp);
// Slice the array to keep only the first 500 elements
let latest500 = dataArray.slice(0, 500);
// Convert back to the original object format
let latest500Data: any = {};
latest500.forEach((item) => {
const { identifier, ...rest } = item;
latest500Data[identifier] = rest;
});
latest500Data[threadIdForLocalStorage] = {
timestamp: Date.now(),
};
localStorage.setItem(
`qmail_threads_viewedtimestamp_${username}`,
JSON.stringify(latest500Data)
);
}, []);
const getMessagesMiddleware = async () => {
await new Promise((res) => {
setTimeout(() => {
res(null);
}, 400);
});
if (firstMount.current) return;
getMessages();
firstMount.current = true;
};
useEffect(() => {
if (currentThreadRef.current?.threadId !== currentThread?.threadId) {
firstMount.current = false;
}
if (currentThread && secretKey && !firstMount.current) {
getMessagesMiddleware();
// saveTimestamp(currentThread, user.name)
}
}, [currentThread, secretKey]);
const messageCallback = useCallback((msg: any) => {
// dispatch(addToHashMapMail(msg))
// setMessages((prev) => [msg, ...prev])
}, []);
const interval = useRef<any>(null);
const checkNewMessages = React.useCallback(
async (groupInfo: any) => {
try {
let threadId = groupInfo.threadId;
const identifier = `thmsg-${threadId}`;
const url = `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=${threadIdentifier}&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 = messages[0];
if (!latestMessage) return;
const findMessage = responseData?.findIndex(
(item: any) => item?.identifier === latestMessage?.identifier
);
let sliceLength = responseData.length;
if (findMessage !== -1) {
sliceLength = findMessage;
}
const newArray = responseData.slice(0, findMessage).reverse();
let fullArrayMsg = [...messages];
for (const message of newArray) {
try {
const responseDataMessage = await getEncryptedResource({
identifier: message.identifier,
name: message.name,
secretKey: secretKeyRef.current,
});
const fullObject = {
...message,
...(responseDataMessage || {}),
id: message.identifier,
};
setHashMapMailMessages((prev) => {
return {
...prev,
[message.identifier]: fullObject,
};
});
const index = messages.findIndex(
(p) => p.identifier === fullObject.identifier
);
if (index !== -1) {
fullArrayMsg[index] = fullObject;
} else {
fullArrayMsg.unshift(fullObject);
}
} catch (error) {}
}
setMessages(fullArrayMsg);
} catch (error) {
} finally {
}
},
[messages]
);
// const checkNewMessagesFunc = useCallback(() => {
// let isCalling = false
// interval.current = setInterval(async () => {
// if (isCalling) return
// isCalling = true
// const res = await checkNewMessages(currentThread)
// isCalling = false
// }, 8000)
// }, [checkNewMessages, currentThrefirstMount.current = truead])
// useEffect(() => {
// checkNewMessagesFunc()
// return () => {
// if (interval?.current) {
// clearInterval(interval.current)
// }
// }
// }, [checkNewMessagesFunc])
const openNewPostWithQuote = useCallback((reply) => {
setPostReply(reply);
}, []);
const closeCallback = useCallback(() => {
setPostReply(null);
}, []);
const threadFetchModeFunc = (e) => {
const mode = e.detail?.mode;
if (mode === "last-page") {
getMailMessages(currentThread, null, null, true);
}
firstMount.current = true;
};
React.useEffect(() => {
subscribeToEvent("threadFetchMode", threadFetchModeFunc);
return () => {
unsubscribeFromEvent("threadFetchMode", threadFetchModeFunc);
};
}, []);
const combinedListTempAndReal = useMemo(() => {
// Combine the two lists
const combined = [...tempPublishedList, ...messages];
// 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) => a.created - b.created);
return sortedList;
}, [tempPublishedList, messages]);
if (!currentThread) return null;
return (
<GroupContainer
sx={{
position: "relative",
overflow: "auto",
width: "100%",
}}
>
<NewThread
groupInfo={groupInfo}
isMessage={true}
currentThread={currentThread}
messageCallback={messageCallback}
members={members}
userInfo={userInfo}
getSecretKey={getSecretKey}
closeCallback={closeCallback}
postReply={postReply}
myName={userInfo?.name}
publishCallback={setTempData}
/>
<ThreadContainerFullWidth>
<ThreadContainer >
<Spacer height="30px" />
<Box
sx={{
width: "100%",
alignItems: "center",
display: "flex",
justifyContent: "space-between",
}}
>
<GroupNameP>{currentThread?.threadData?.title}</GroupNameP>
<ShowMessageReturnButton
onClick={() => {
setMessages([]);
closeThread();
}}
>
<MailIconImg src={ReturnSVG} />
<ComposeP>Return to Threads</ComposeP>
</ShowMessageReturnButton>
</Box>
<Box
sx={{
width: "100%",
alignItems: "center",
display: "flex",
justifyContent: "center",
gap: "5px",
}}
>
<Button
onClick={() => {
getMailMessages(currentThread, null, null, false);
}}
disabled={!hasFirstPage}
variant="contained"
>
First Page
</Button>
<Button
onClick={() => {
getMailMessages(
currentThread,
messages[0].created,
null,
false
);
}}
disabled={!hasPreviousPage}
variant="contained"
>
Previous Page
</Button>
<Button
onClick={() => {
getMailMessages(
currentThread,
null,
messages[messages.length - 1].created,
false
);
}}
disabled={!hasNextPage}
variant="contained"
>
Next page
</Button>
<Button
onClick={() => {
getMailMessages(currentThread, null, null, true);
}}
disabled={!hasLastPage}
variant="contained"
>
Last page
</Button>
</Box>
<Spacer height="60px" />
{combinedListTempAndReal.map((message) => {
let fullMessage = message;
if (hashMapMailMessages[message?.identifier]) {
fullMessage = hashMapMailMessages[message.identifier];
return (
<ShowMessage
key={message?.identifier}
message={fullMessage}
openNewPostWithQuote={openNewPostWithQuote}
/>
);
} else if(message?.tempData){
return (
<ShowMessage
key={message?.identifier}
message={message?.tempData}
openNewPostWithQuote={openNewPostWithQuote}
/>
);
}
return (
<SingleThreadParent>
<Skeleton
variant="rectangular"
style={{
width: "100%",
height: 60,
borderRadius: "8px",
overflow: "hidden",
}}
/>
</SingleThreadParent>
);
})}
<div ref={containerRef} />
{!hasLastPage && !isLoading && (
<>
<Spacer height="20px" />
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "flex-end",
}}
>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => {
getMailMessages(currentThread, null, null, true);
}}
sx={{
color: "white",
}}
>
Refetch page
</Button>
</Box>
</>
)}
{messages?.length > 4 && (
<>
<Spacer height="30px" />
<Box
sx={{
width: "100%",
alignItems: "center",
display: "flex",
justifyContent: "center",
gap: "5px",
}}
>
<Button
onClick={() => {
getMailMessages(currentThread, null, null, false);
}}
disabled={!hasFirstPage}
variant="contained"
>
First Page
</Button>
<Button
onClick={() => {
getMailMessages(
currentThread,
messages[0].created,
null,
false
);
}}
disabled={!hasPreviousPage}
variant="contained"
>
Previous Page
</Button>
<Button
onClick={() => {
getMailMessages(
currentThread,
null,
messages[messages.length - 1].created,
false
);
}}
disabled={!hasNextPage}
variant="contained"
>
Next page
</Button>
<Button
onClick={() => {
getMailMessages(currentThread, null, null, true);
}}
disabled={!hasLastPage}
variant="contained"
>
Last page
</Button>
</Box>
<Spacer height="30px" />
</>
)}
</ThreadContainer>
</ThreadContainerFullWidth>
{/* {messages.length >= 20 && (
<LazyLoad onLoadMore={()=> getMailMessages(currentThread, false, true)}></LazyLoad>
)} */}
<LoadingSnackbar
open={isLoading}
info={{
message: "Loading posts... please wait.",
}}
/>
</GroupContainer>
);
};

View File

@@ -0,0 +1,71 @@
.ql-editor {
min-height: 200px;
width: 100%;
color: black;
font-size: 16px;
font-family: Roboto;
max-height: 225px;
overflow-y: scroll;
padding: 0px !important;
}
.ql-editor::-webkit-scrollbar-track {
background-color: transparent;
cursor: default;
}
.ql-editor::-webkit-scrollbar-track:hover {
background-color: transparent;
}
.ql-editor::-webkit-scrollbar {
width: 16px;
height: 10px;
background-color: rgba(229, 229, 229, 0.70);
}
.ql-editor::-webkit-scrollbar-thumb {
background-color: #B0B0B0;
border-radius: 8px;
background-clip: content-box;
border: 4px solid transparent;
}
.ql-editor img {
cursor: default;
}
.ql-editor-display {
min-height: 20px;
width: 100%;
color: black;
font-size: 16px;
font-family: Roboto;
padding: 0px !important;
}
.ql-editor-display img {
cursor: default;
}
.ql-container {
font-size: 16px
}
.ql-toolbar .ql-stroke {
fill: none !important;
stroke: black !important;
}
.ql-toolbar .ql-fill {
fill: black !important;
stroke: none !important;
}
.ql-toolbar .ql-picker {
color: black !important;
}
.ql-toolbar .ql-picker-options {
background-color: white !important;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
import * as React from "react";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Checkbox from "@mui/material/Checkbox";
import IconButton from "@mui/material/IconButton";
import CommentIcon from "@mui/icons-material/Comment";
import InfoIcon from "@mui/icons-material/Info";
import GroupAddIcon from '@mui/icons-material/GroupAdd';
import { executeEvent } from "../../utils/events";
import { Box, Typography } from "@mui/material";
import { Spacer } from "../../common/Spacer";
import { getGroupNames } from "./UserListOfInvites";
import { CustomLoader } from "../../common/CustomLoader";
import { getBaseApiReact } from "../../App";
export const GroupInvites = ({ myAddress, setOpenAddGroup }) => {
const [groupsWithJoinRequests, setGroupsWithJoinRequests] = React.useState([])
const [loading, setLoading] = React.useState(true)
const getJoinRequests = async ()=> {
try {
setLoading(true)
const response = await fetch(`${getBaseApiReact()}/groups/invites/${myAddress}/?limit=0`);
const data = await response.json();
const resMoreData = await getGroupNames(data)
setGroupsWithJoinRequests(resMoreData)
} catch (error) {
} finally {
setLoading(false)
}
}
React.useEffect(() => {
if (myAddress) {
getJoinRequests()
}
}, [myAddress]);
return (
<Box sx={{
width: '360px',
display: 'flex',
flexDirection: 'column',
bgcolor: "background.paper",
padding: '20px'
}}>
<Typography sx={{
fontSize: '14px'
}}>Group Invites</Typography>
<Spacer height="10px" />
{loading && groupsWithJoinRequests.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<CustomLoader />
</Box>
)}
{!loading && groupsWithJoinRequests.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<Typography sx={{
fontSize: '12px'
}}>No invites</Typography>
</Box>
)}
<List sx={{ width: "100%", maxWidth: 360, bgcolor: "background.paper", maxHeight: '300px', overflow: 'auto' }}>
{groupsWithJoinRequests?.map((group)=> {
return (
<ListItem
key={group?.groupId}
onClick={()=> {
setOpenAddGroup(true)
setTimeout(() => {
executeEvent("openGroupInvitesRequest", {});
}, 300);
}}
disablePadding
secondaryAction={
<IconButton edge="end" aria-label="comments">
<GroupAddIcon
sx={{
color: "white",
}}
/>
</IconButton>
}
>
<ListItemButton disableRipple role={undefined} dense>
<ListItemText primary={`${group?.groupName} has invited you`} />
</ListItemButton>
</ListItem>
)
})}
</List>
</Box>
);
};

View File

@@ -0,0 +1,170 @@
import * as React from "react";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Checkbox from "@mui/material/Checkbox";
import IconButton from "@mui/material/IconButton";
import CommentIcon from "@mui/icons-material/Comment";
import InfoIcon from "@mui/icons-material/Info";
import { RequestQueueWithPromise } from "../../utils/queue/queue";
import GroupAddIcon from '@mui/icons-material/GroupAdd';
import { executeEvent } from "../../utils/events";
import { Box, Typography } from "@mui/material";
import { Spacer } from "../../common/Spacer";
import { CustomLoader } from "../../common/CustomLoader";
import { getBaseApi } from "../../background";
import { getBaseApiReact } from "../../App";
export const requestQueueGroupJoinRequests = new RequestQueueWithPromise(3)
export const GroupJoinRequests = ({ myAddress, groups, setOpenManageMembers, getTimestampEnterChat, setSelectedGroup, setGroupSection }) => {
const [groupsWithJoinRequests, setGroupsWithJoinRequests] = React.useState([])
const [loading, setLoading] = React.useState(true)
const getJoinRequests = async ()=> {
try {
setLoading(true)
let groupsAsAdmin = []
const getAllGroupsAsAdmin = groups.map(async (group)=> {
const isAdminResponse = await requestQueueGroupJoinRequests.enqueue(()=> {
return fetch(
`${getBaseApiReact()}/groups/members/${group.groupId}?limit=0&onlyAdmins=true`
);
})
const isAdminData = await isAdminResponse.json()
const findMyself = isAdminData?.members?.find((member)=> member.member === myAddress)
if(findMyself){
groupsAsAdmin.push(group)
}
return true
})
// const getJoinGroupRequests = groupsAsAdmin.map(async (group)=> {
// console.log('getJoinGroupRequests', group)
// const joinRequestResponse = await requestQueueGroupJoinRequests.enqueue(()=> {
// return fetch(
// `${getBaseApiReact()}/groups/joinrequests/${group.groupId}`
// );
// })
// const joinRequestData = await joinRequestResponse.json()
// return {
// group,
// data: joinRequestData
// }
// })
await Promise.all(getAllGroupsAsAdmin)
const res = await Promise.all(groupsAsAdmin.map(async (group)=> {
const joinRequestResponse = await requestQueueGroupJoinRequests.enqueue(()=> {
return fetch(
`${getBaseApiReact()}/groups/joinrequests/${group.groupId}`
);
})
const joinRequestData = await joinRequestResponse.json()
return {
group,
data: joinRequestData
}
}))
setGroupsWithJoinRequests(res)
} catch (error) {
} finally {
setLoading(false)
}
}
React.useEffect(() => {
if (myAddress && groups.length > 0) {
getJoinRequests()
} else {
setLoading(false)
}
}, [myAddress, groups]);
return (
<Box sx={{
width: '360px',
display: 'flex',
flexDirection: 'column',
bgcolor: "background.paper",
padding: '20px'
}}>
<Typography sx={{
fontSize: '14px'
}}>Join Requests</Typography>
<Spacer height="10px" />
{loading && groupsWithJoinRequests.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<CustomLoader />
</Box>
)}
{!loading && groupsWithJoinRequests.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<Typography sx={{
fontSize: '12px'
}}>No join requests</Typography>
</Box>
)}
<List sx={{ width: "100%", maxWidth: 360, bgcolor: "background.paper", maxHeight: '300px', overflow: 'auto' }}>
{groupsWithJoinRequests?.map((group)=> {
if(group?.data?.length === 0) return null
return (
<ListItem
key={group?.groupId}
onClick={()=> {
setSelectedGroup(group?.group)
getTimestampEnterChat()
setGroupSection("announcement")
setOpenManageMembers(true)
setTimeout(() => {
executeEvent("openGroupJoinRequest", {});
}, 300);
}}
disablePadding
secondaryAction={
<IconButton edge="end" aria-label="comments">
<GroupAddIcon
sx={{
color: "white",
}}
/>
</IconButton>
}
>
<ListItemButton disableRipple role={undefined} dense>
<ListItemText primary={`${group?.group?.groupName} has ${group?.data?.length} pending join requests.`} />
</ListItemButton>
</ListItem>
)
})}
</List>
</Box>
);
};

View File

@@ -0,0 +1,108 @@
import { LoadingButton } from "@mui/lab";
import {
Box,
Button,
Input,
MenuItem,
Select,
SelectChangeEvent,
} from "@mui/material";
import React, { useState } from "react";
import { Spacer } from "../../common/Spacer";
import { Label } from "./AddGroup";
import { getFee } from "../../background";
export const InviteMember = ({ groupId, setInfoSnack, setOpenSnack, show }) => {
const [value, setValue] = useState("");
const [expiryTime, setExpiryTime] = useState<string>('259200');
const [isLoadingInvite, setIsLoadingInvite] = useState(false)
const inviteMember = async () => {
try {
const fee = await getFee('GROUP_INVITE')
await show({
message: "Would you like to perform a GROUP_INVITE transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoadingInvite(true)
if (!expiryTime || !value) return;
new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "inviteToGroup",
payload: {
groupId,
qortalAddress: value,
inviteTime: +expiryTime,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message: `Successfully invited ${value}. It may take a couple of minutes for the changes to propagate`,
});
setOpenSnack(true);
res(response);
setValue("");
return
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
);
});
} catch (error) {} finally {
setIsLoadingInvite(false)
}
};
const handleChange = (event: SelectChangeEvent) => {
setExpiryTime(event.target.value as string);
};
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
Invite member
<Spacer height="20px" />
<Input
value={value}
placeholder="Name or address"
onChange={(e) => setValue(e.target.value)}
/>
<Spacer height="20px" />
<Label>Invitation Expiry Time</Label>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={expiryTime}
label="Invitation Expiry Time"
onChange={handleChange}
>
<MenuItem value={10800}>3 hours</MenuItem>
<MenuItem value={21600}>6 hours</MenuItem>
<MenuItem value={43200}>12 hours</MenuItem>
<MenuItem value={86400}>1 day</MenuItem>
<MenuItem value={259200}>3 days</MenuItem>
<MenuItem value={432000}>5 days</MenuItem>
<MenuItem value={604800}>7 days</MenuItem>
<MenuItem value={864000}>10 days</MenuItem>
<MenuItem value={1296000}>15 days</MenuItem>
<MenuItem value={2592000}>30 days</MenuItem>
</Select>
<Spacer height="20px" />
<LoadingButton variant="contained" loadingPosition="start" loading={isLoadingInvite} onClick={inviteMember}>Invite</LoadingButton>
</Box>
);
};

View File

@@ -0,0 +1,188 @@
import React, { useEffect, useRef, useState } from 'react';
import { Avatar, Box, Button, ListItem, ListItemAvatar, ListItemButton, ListItemText, Popover } from '@mui/material';
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import { getNameInfo } from './Group';
import { getBaseApi, getFee } from '../../background';
import { LoadingButton } from '@mui/lab';
import { getBaseApiReact } from '../../App';
export const getMemberInvites = async (groupNumber) => {
const response = await fetch(`${getBaseApiReact()}/groups/bans/${groupNumber}?limit=0`);
const groupData = await response.json();
return groupData;
}
const getNames = async (listOfMembers) => {
let members = [];
if (listOfMembers && Array.isArray(listOfMembers)) {
for (const member of listOfMembers) {
if (member.offender) {
const name = await getNameInfo(member.offender);
if (name) {
members.push({ ...member, name });
}
}
}
}
return members;
}
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const ListOfBans = ({ groupId, setInfoSnack, setOpenSnack, show }) => {
const [bans, setBans] = useState([]);
const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to
const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Track which list item has the popover open
const listRef = useRef();
const [isLoadingUnban, setIsLoadingUnban] = useState(false);
const getInvites = async (groupId) => {
try {
const res = await getMemberInvites(groupId);
const resWithNames = await getNames(res);
setBans(resWithNames);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
if (groupId) {
getInvites(groupId);
}
}, [groupId]);
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const handleCancelBan = async (address)=> {
try {
const fee = await getFee('CANCEL_GROUP_BAN')
await show({
message: "Would you like to perform a CANCEL_GROUP_BAN transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoadingUnban(true)
new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "cancelBan", payload: {
groupId,
qortalAddress: address,
}}, (response) => {
if (!response?.error) {
res(response)
setIsLoadingUnban(false)
setInfoSnack({
type: "success",
message: "Successfully unbanned user. It may take a couple of minutes for the changes to propagate",
});
handlePopoverClose();
setOpenSnack(true);
return
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error)
});
})
} catch (error) {
} finally {
setIsLoadingUnban(false)
}
}
const rowRenderer = ({ index, key, parent, style }) => {
const member = bans[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style} onLoad={measure}>
<ListItem disablePadding>
<Popover
open={openPopoverIndex === index}
anchorEl={popoverAnchor}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
style={{ marginTop: "8px" }}
>
<Box
sx={{
width: "325px",
height: "250px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
padding: "10px",
}}
>
<LoadingButton loading={isLoadingUnban}
loadingPosition="start"
variant="contained" onClick={()=> handleCancelBan(member?.offender)}>Cancel Ban</LoadingButton>
</Box>
</Popover>
<ListItemButton onClick={(event) => handlePopoverOpen(event, index)}>
<ListItemAvatar>
<Avatar
alt={member?.name}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${member?.name}/qortal_avatar?async=true`}
/>
</ListItemAvatar>
<ListItemText primary={member?.name || member?.offender} />
</ListItemButton>
</ListItem>
</div>
)}
</CellMeasurer>
);
};
return (
<div>
<p>Ban list</p>
<div style={{ position: 'relative', height: '500px', width: '600px', display: 'flex', flexDirection: 'column', flexShrink: 1 }}>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={bans.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
import React, { useEffect, useRef, useState } from 'react';
import { Avatar, Box, Button, ListItem, ListItemAvatar, ListItemButton, ListItemText, Popover } from '@mui/material';
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import { getNameInfo } from './Group';
import { getBaseApi, getFee } from '../../background';
import { LoadingButton } from '@mui/lab';
import { getBaseApiReact } from '../../App';
export const getMemberInvites = async (groupNumber) => {
const response = await fetch(`${getBaseApiReact()}/groups/invites/group/${groupNumber}?limit=0`);
const groupData = await response.json();
return groupData;
}
const getNames = async (listOfMembers) => {
let members = [];
if (listOfMembers && Array.isArray(listOfMembers)) {
for (const member of listOfMembers) {
if (member.invitee) {
const name = await getNameInfo(member.invitee);
if (name) {
members.push({ ...member, name });
}
}
}
}
return members;
}
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const ListOfInvites = ({ groupId, setInfoSnack, setOpenSnack, show }) => {
const [invites, setInvites] = useState([]);
const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to
const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Track which list item has the popover open
const [isLoadingCancelInvite, setIsLoadingCancelInvite] = useState(false);
const listRef = useRef();
const getInvites = async (groupId) => {
try {
const res = await getMemberInvites(groupId);
const resWithNames = await getNames(res);
setInvites(resWithNames);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
if (groupId) {
getInvites(groupId);
}
}, [groupId]);
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const handleCancelInvitation = async (address)=> {
try {
const fee = await getFee('CANCEL_GROUP_INVITE')
await show({
message: "Would you like to perform a CANCEL_GROUP_INVITE transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoadingCancelInvite(true)
await new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "cancelInvitationToGroup", payload: {
groupId,
qortalAddress: address,
}}, (response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message: "Successfully canceled invitation. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
setIsLoadingCancelInvite(true)
res(response)
return
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error)
});
})
} catch (error) {
} finally {
setIsLoadingCancelInvite(false)
}
}
const rowRenderer = ({ index, key, parent, style }) => {
const member = invites[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style} onLoad={measure}>
<ListItem disablePadding>
<Popover
open={openPopoverIndex === index}
anchorEl={popoverAnchor}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
style={{ marginTop: "8px" }}
>
<Box
sx={{
width: "325px",
height: "250px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
padding: "10px",
}}
>
<LoadingButton loading={isLoadingCancelInvite}
loadingPosition="start"
variant="contained" onClick={()=> handleCancelInvitation(member?.invitee)}>Cancel Invitation</LoadingButton>
</Box>
</Popover>
<ListItemButton onClick={(event) => handlePopoverOpen(event, index)}>
<ListItemAvatar>
<Avatar
alt={member?.name}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${member?.name}/qortal_avatar?async=true`}
/>
</ListItemAvatar>
<ListItemText primary={member?.name || member?.invitee} />
</ListItemButton>
</ListItem>
</div>
)}
</CellMeasurer>
);
};
return (
<div>
<p>Invitees list</p>
<div style={{ position: 'relative', height: '500px', width: '600px', display: 'flex', flexDirection: 'column', flexShrink: 1 }}>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={invites.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
import React, { useEffect, useRef, useState } from 'react';
import { Avatar, Box, Button, ListItem, ListItemAvatar, ListItemButton, ListItemText, Popover } from '@mui/material';
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import { getNameInfo } from './Group';
import { getBaseApi, getFee } from '../../background';
import { LoadingButton } from '@mui/lab';
import { getBaseApiReact } from '../../App';
export const getMemberInvites = async (groupNumber) => {
const response = await fetch(`${getBaseApiReact()}/groups/joinrequests/${groupNumber}?limit=0`);
const groupData = await response.json();
return groupData;
}
const getNames = async (listOfMembers) => {
let members = [];
if (listOfMembers && Array.isArray(listOfMembers)) {
for (const member of listOfMembers) {
if (member.joiner) {
const name = await getNameInfo(member.joiner);
if (name) {
members.push({ ...member, name });
}
}
}
}
return members;
}
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const ListOfJoinRequests = ({ groupId, setInfoSnack, setOpenSnack, show }) => {
const [invites, setInvites] = useState([]);
const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to
const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Track which list item has the popover open
const listRef = useRef();
const [isLoadingAccept, setIsLoadingAccept] = useState(false);
const getInvites = async (groupId) => {
try {
const res = await getMemberInvites(groupId);
const resWithNames = await getNames(res);
setInvites(resWithNames);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
if (groupId) {
getInvites(groupId);
}
}, [groupId]);
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const handleAcceptJoinRequest = async (address)=> {
try {
const fee = await getFee('GROUP_INVITE')
await show({
message: "Would you like to perform a GROUP_INVITE transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoadingAccept(true)
await new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "inviteToGroup", payload: {
groupId,
qortalAddress: address,
inviteTime: 10800,
}}, (response) => {
if (!response?.error) {
setIsLoadingAccept(false)
setInfoSnack({
type: "success",
message: "Successfully accepted join request. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
res(response)
return
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error)
});
})
} catch (error) {
} finally {
setIsLoadingAccept(false)
}
}
const rowRenderer = ({ index, key, parent, style }) => {
const member = invites[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style} onLoad={measure}>
<ListItem disablePadding>
<Popover
open={openPopoverIndex === index}
anchorEl={popoverAnchor}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
style={{ marginTop: "8px" }}
>
<Box
sx={{
width: "325px",
height: "250px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
padding: "10px",
}}
>
<LoadingButton loading={isLoadingAccept}
loadingPosition="start"
variant="contained" onClick={()=> handleAcceptJoinRequest(member?.joiner)}>Accept</LoadingButton>
</Box>
</Popover>
<ListItemButton onClick={(event) => handlePopoverOpen(event, index)}>
<ListItemAvatar>
<Avatar
alt={member?.name}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${member?.name}/qortal_avatar?async=true`}
/>
</ListItemAvatar>
<ListItemText primary={member?.name || member?.joiner} />
</ListItemButton>
</ListItem>
</div>
)}
</CellMeasurer>
);
};
return (
<div>
<p>Join request list</p>
<div style={{ position: 'relative', height: '500px', width: '600px', display: 'flex', flexDirection: 'column', flexShrink: 1 }}>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={invites.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</div>
);
}

View File

@@ -0,0 +1,385 @@
import {
Avatar,
Box,
Button,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemText,
Popover,
Typography,
} from "@mui/material";
import React, { useRef, useState } from "react";
import {
AutoSizer,
CellMeasurer,
CellMeasurerCache,
List,
} from "react-virtualized";
import { LoadingButton } from "@mui/lab";
import { getBaseApi, getFee } from "../../background";
import { getBaseApiReact } from "../../App";
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
const ListOfMembers = ({
members,
groupId,
setInfoSnack,
setOpenSnack,
isAdmin,
isOwner,
show,
}) => {
const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to
const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Track which list item has the popover open
const [isLoadingKick, setIsLoadingKick] = useState(false);
const [isLoadingBan, setIsLoadingBan] = useState(false);
const [isLoadingMakeAdmin, setIsLoadingMakeAdmin] = useState(false);
const [isLoadingRemoveAdmin, setIsLoadingRemoveAdmin] = useState(false);
const listRef = useRef();
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const handleKick = async (address) => {
try {
const fee = await getFee("GROUP_KICK");
await show({
message: "Would you like to perform a GROUP_KICK transaction?",
publishFee: fee.fee + " QORT",
});
setIsLoadingKick(true);
new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "kickFromGroup",
payload: {
groupId,
qortalAddress: address,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message:
"Successfully kicked member from group. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
res(response);
return;
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
);
});
} catch (error) {
} finally {
setIsLoadingKick(false);
}
};
const handleBan = async (address) => {
try {
const fee = await getFee("GROUP_BAN");
await show({
message: "Would you like to perform a GROUP_BAN transaction?",
publishFee: fee.fee + " QORT",
});
setIsLoadingBan(true);
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "banFromGroup",
payload: {
groupId,
qortalAddress: address,
rBanTime: 0,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message:
"Successfully banned member from group. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
res(response);
return;
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
);
});
} catch (error) {
} finally {
setIsLoadingBan(false);
}
};
const makeAdmin = async (address) => {
try {
const fee = await getFee("ADD_GROUP_ADMIN");
await show({
message: "Would you like to perform a ADD_GROUP_ADMIN transaction?",
publishFee: fee.fee + " QORT",
});
setIsLoadingMakeAdmin(true);
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "makeAdmin",
payload: {
groupId,
qortalAddress: address,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message:
"Successfully made member an admin. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
res(response);
return;
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
);
});
} catch (error) {
} finally {
setIsLoadingMakeAdmin(false);
}
};
const removeAdmin = async (address) => {
try {
const fee = await getFee("REMOVE_GROUP_ADMIN");
await show({
message: "Would you like to perform a REMOVE_GROUP_ADMIN transaction?",
publishFee: fee.fee + " QORT",
});
setIsLoadingRemoveAdmin(true);
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "removeAdmin",
payload: {
groupId,
qortalAddress: address,
},
},
(response) => {
if (!response?.error) {
setInfoSnack({
type: "success",
message:
"Successfully removed member as an admin. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
res(response);
return;
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
);
});
} catch (error) {
} finally {
setIsLoadingRemoveAdmin(false);
}
};
const rowRenderer = ({ index, key, parent, style }) => {
const member = members[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style} onLoad={measure}>
<Popover
open={openPopoverIndex === index}
anchorEl={popoverAnchor}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
style={{ marginTop: "8px" }}
>
<Box
sx={{
width: "325px",
height: "250px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
padding: "10px",
}}
>
{isOwner && (
<>
<LoadingButton
loading={isLoadingKick}
loadingPosition="start"
variant="contained"
onClick={() => handleKick(member?.member)}
>
Kick member from group
</LoadingButton>
<LoadingButton
loading={isLoadingBan}
loadingPosition="start"
variant="contained"
onClick={() => handleBan(member?.member)}
>
Ban member from group
</LoadingButton>
<LoadingButton
loading={isLoadingMakeAdmin}
loadingPosition="start"
variant="contained"
onClick={() => makeAdmin(member?.member)}
>
Make an admin
</LoadingButton>
<LoadingButton
loading={isLoadingRemoveAdmin}
loadingPosition="start"
variant="contained"
onClick={() => removeAdmin(member?.member)}
>
Remove as admin
</LoadingButton>
</>
)}
</Box>
</Popover>
<ListItem
key={member?.member}
// secondaryAction={
// <Checkbox
// edge="end"
// onChange={handleToggle(value)}
// checked={checked.indexOf(value) !== -1}
// inputProps={{ 'aria-labelledby': labelId }}
// />
// }
disablePadding
>
<ListItemButton
onClick={(event) => handlePopoverOpen(event, index)}
>
<ListItemAvatar>
<Avatar
alt={member?.name || member?.member}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${member?.name}/qortal_avatar?async=true`}
/>
</ListItemAvatar>
<ListItemText
id={""}
primary={member?.name || member?.member}
/>
{member?.isAdmin && (
<Typography sx={{
color: 'white',
marginLeft: 'auto'
}}>Admin</Typography>
)}
</ListItemButton>
</ListItem>
</div>
)}
</CellMeasurer>
);
};
return (
<div>
<p>Member list</p>
<div
style={{
position: "relative",
height: "500px",
width: "600px",
display: "flex",
flexDirection: "column",
flexShrink: 1,
}}
>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={members.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
// onScroll={handleScroll}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</div>
);
};
export default ListOfMembers;

View File

@@ -0,0 +1,138 @@
import * as React from "react";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Checkbox from "@mui/material/Checkbox";
import IconButton from "@mui/material/IconButton";
import CommentIcon from "@mui/icons-material/Comment";
import InfoIcon from "@mui/icons-material/Info";
import GroupAddIcon from '@mui/icons-material/GroupAdd';
import { executeEvent } from "../../utils/events";
import { Box, Typography } from "@mui/material";
import { Spacer } from "../../common/Spacer";
import { getGroupNames } from "./UserListOfInvites";
import { CustomLoader } from "../../common/CustomLoader";
import VisibilityIcon from '@mui/icons-material/Visibility';
export const ListOfThreadPostsWatched = () => {
const [posts, setPosts] = React.useState([])
const [loading, setLoading] = React.useState(true)
const getPosts = async ()=> {
try {
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "getThreadActivity",
payload: {
},
},
(response) => {
if (!response?.error) {
if(!response) {
res(null)
return
}
const uniquePosts = response.reduce((acc, current) => {
const x = acc.find(item => item?.thread?.threadId === current?.thread?.threadId);
if (!x) {
return acc.concat([current]);
} else {
return acc;
}
}, []);
setPosts(uniquePosts)
res(uniquePosts);
return
}
rej(response.error);
}
);
});
} catch (error) {
} finally {
setLoading(false)
}
}
React.useEffect(() => {
getPosts()
}, []);
return (
<Box sx={{
width: '360px',
display: 'flex',
flexDirection: 'column',
bgcolor: "background.paper",
padding: '20px'
}}>
<Typography sx={{
fontSize: '14px'
}}>New Thread Posts</Typography>
<Spacer height="10px" />
{loading && posts.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<CustomLoader />
</Box>
)}
{!loading && posts.length === 0 && (
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}>
<Typography sx={{
fontSize: '12px'
}}>No thread post notifications</Typography>
</Box>
)}
<List sx={{ width: "100%", maxWidth: 360, bgcolor: "background.paper", maxHeight: '300px', overflow: 'auto' }}>
{posts?.map((post)=> {
return (
<ListItem
key={post?.thread?.threadId}
onClick={()=> {
executeEvent("openThreadNewPost", {
data: post
});
}}
disablePadding
secondaryAction={
<IconButton edge="end" aria-label="comments">
<VisibilityIcon
sx={{
color: "red",
}}
/>
</IconButton>
}
>
<ListItemButton disableRipple role={undefined} dense>
<ListItemText primary={`New post in ${post?.thread?.threadData?.title}`} />
</ListItemButton>
</ListItem>
)
})}
</List>
</Box>
);
};

View File

@@ -0,0 +1,316 @@
import * as React from "react";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import ListItemText from "@mui/material/ListItemText";
import ListItemButton from "@mui/material/ListItemButton";
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import CloseIcon from "@mui/icons-material/Close";
import Slide from "@mui/material/Slide";
import { TransitionProps } from "@mui/material/transitions";
import ListOfMembers from "./ListOfMembers";
import { InviteMember } from "./InviteMember";
import { ListOfInvites } from "./ListOfInvites";
import { ListOfBans } from "./ListOfBans";
import { ListOfJoinRequests } from "./ListOfJoinRequests";
import { Box, Tab, Tabs } from "@mui/material";
import { CustomizedSnackbars } from "../Snackbar/Snackbar";
import { MyContext } from "../../App";
import { getGroupMembers, getNames } from "./Group";
import { LoadingSnackbar } from "../Snackbar/LoadingSnackbar";
import { getFee } from "../../background";
import { LoadingButton } from "@mui/lab";
import { subscribeToEvent, unsubscribeFromEvent } from "../../utils/events";
function a11yProps(index: number) {
return {
id: `simple-tab-${index}`,
"aria-controls": `simple-tabpanel-${index}`,
};
}
const Transition = React.forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement;
},
ref: React.Ref<unknown>
) {
return <Slide direction="up" ref={ref} {...props} />;
});
export const ManageMembers = ({
address,
open,
setOpen,
selectedGroup,
isAdmin,
isOwner
}) => {
const [membersWithNames, setMembersWithNames] = React.useState([]);
const [tab, setTab] = React.useState("create");
const [value, setValue] = React.useState(0);
const [openSnack, setOpenSnack] = React.useState(false);
const [infoSnack, setInfoSnack] = React.useState(null);
const [isLoadingMembers, setIsLoadingMembers] = React.useState(false)
const [isLoadingLeave, setIsLoadingLeave] = React.useState(false)
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
const { show, setTxList } = React.useContext(MyContext);
const handleClose = () => {
setOpen(false);
};
const handleLeaveGroup = async () => {
try {
setIsLoadingLeave(true)
const fee = await getFee('LEAVE_GROUP')
await show({
message: "Would you like to perform an LEAVE_GROUP transaction?" ,
publishFee: fee.fee + ' QORT'
})
await new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
action: "leaveGroup",
payload: {
groupId: selectedGroup?.groupId,
},
},
(response) => {
if (!response?.error) {
setTxList((prev)=> [{
...response,
type: 'leave-group',
label: `Left Group ${selectedGroup?.groupName}: awaiting confirmation`,
labelDone: `Left Group ${selectedGroup?.groupName}: success !`,
done: false,
groupId: selectedGroup?.groupId,
}, ...prev])
res(response);
setInfoSnack({
type: "success",
message: "Successfully requested to leave group. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
return
}
rej(response.error);
}
);
});
} catch (error) {} finally {
setIsLoadingLeave(false)
}
};
const getMembers = async (groupId) => {
try {
setIsLoadingMembers(true)
const res = await getGroupMembers(groupId);
const resWithNames = await getNames(res.members);
setMembersWithNames(resWithNames);
setIsLoadingMembers(false)
} catch (error) {}
};
React.useEffect(()=> {
if(selectedGroup?.groupId){
getMembers(selectedGroup?.groupId)
}
}, [selectedGroup?.groupId])
const openGroupJoinRequestFunc = ()=> {
setValue(4)
}
React.useEffect(() => {
subscribeToEvent("openGroupJoinRequest", openGroupJoinRequestFunc);
return () => {
unsubscribeFromEvent("openGroupJoinRequest", openGroupJoinRequestFunc);
};
}, []);
return (
<React.Fragment>
<Dialog
fullScreen
open={open}
onClose={handleClose}
TransitionComponent={Transition}
>
<AppBar sx={{ position: "relative", bgcolor: "#232428" }}>
<Toolbar>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
Manage Members
</Typography>
<IconButton
edge="start"
color="inherit"
onClick={handleClose}
aria-label="close"
>
<CloseIcon />
</IconButton>
</Toolbar>
</AppBar>
<Box
sx={{
bgcolor: "#27282c",
flexGrow: 1,
overflowY: "auto",
color: "white",
}}
>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs
sx={{
"& .MuiTabs-indicator": {
backgroundColor: "white",
},
}}
value={value}
onChange={handleChange}
aria-label="basic tabs example"
>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="List of members"
{...a11yProps(0)}
/>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="Invite new member"
{...a11yProps(1)}
/>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="List of invites"
{...a11yProps(2)}
/>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="List of bans"
{...a11yProps(3)}
/>
<Tab
sx={{
"&.Mui-selected": {
color: `white`,
},
}}
label="Join requests"
{...a11yProps(4)}
/>
</Tabs>
</Box>
{selectedGroup?.groupId && !isOwner && (
<LoadingButton loading={isLoadingLeave} loadingPosition="start"
variant="contained" onClick={handleLeaveGroup}>
Leave Group
</LoadingButton>
)}
{value === 0 && (
<Box
sx={{
width: "100%",
padding: "25px",
}}
>
<ListOfMembers
members={membersWithNames || []}
groupId={selectedGroup?.groupId}
setOpenSnack={setOpenSnack}
setInfoSnack={setInfoSnack}
isAdmin={isAdmin}
isOwner={isOwner}
show={show}
/>
</Box>
)}
{value === 1 && (
<Box
sx={{
width: "100%",
padding: "25px",
}}
>
<InviteMember show={show} groupId={selectedGroup?.groupId} setOpenSnack={setOpenSnack} setInfoSnack={setInfoSnack} />
</Box>
)}
{value === 2 && (
<Box
sx={{
width: "100%",
padding: "25px",
}}
>
<ListOfInvites show={show} groupId={selectedGroup?.groupId} setOpenSnack={setOpenSnack} setInfoSnack={setInfoSnack} />
</Box>
)}
{value === 3 && (
<Box
sx={{
width: "100%",
padding: "25px",
}}
>
<ListOfBans show={show} groupId={selectedGroup?.groupId} setOpenSnack={setOpenSnack} setInfoSnack={setInfoSnack} />
</Box>
)}
{value === 4 && (
<Box
sx={{
width: "100%",
padding: "25px",
}}
>
<ListOfJoinRequests show={show} setOpenSnack={setOpenSnack} setInfoSnack={setInfoSnack} groupId={selectedGroup?.groupId} />
</Box>
)}
</Box>
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
<LoadingSnackbar
open={isLoadingMembers}
info={{
message: "Loading member list with names... please wait.",
}}
/>
</Dialog>
</React.Fragment>
);
};

View File

@@ -0,0 +1,166 @@
import * as React from "react";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Checkbox from "@mui/material/Checkbox";
import IconButton from "@mui/material/IconButton";
import CommentIcon from "@mui/icons-material/Comment";
import InfoIcon from "@mui/icons-material/Info";
import { Box, Typography } from "@mui/material";
import { Spacer } from "../../common/Spacer";
export const ThingsToDoInitial = ({ myAddress, name, hasGroups, balance }) => {
const [checked1, setChecked1] = React.useState(false);
const [checked2, setChecked2] = React.useState(false);
const [checked3, setChecked3] = React.useState(false);
// const getAddressInfo = async (address) => {
// const response = await fetch(getBaseApiReact() + "/addresses/" + address);
// const data = await response.json();
// if (data.error && data.error === 124) {
// setChecked1(false);
// } else if (data.address) {
// setChecked1(true);
// }
// };
// const checkInfo = async () => {
// try {
// getAddressInfo(myAddress);
// } catch (error) {}
// };
React.useEffect(() => {
if (balance && +balance >= 6) {
setChecked1(true)
}
}, [balance]);
React.useEffect(()=> {
if(hasGroups) setChecked3(true)
}, [hasGroups])
React.useEffect(()=> {
if(name) setChecked2(true)
}, [name])
return (
<Box sx={{
width: '360px',
display: 'flex',
flexDirection: 'column',
bgcolor: "background.paper",
padding: '20px'
}}>
<Typography sx={{
fontSize: '14px'
}}>Suggestion: Complete the following</Typography>
<Spacer height="10px" />
<List sx={{ width: "100%", maxWidth: 360 }}>
<ListItem
// secondaryAction={
// <IconButton edge="end" aria-label="comments">
// <InfoIcon
// sx={{
// color: "white",
// }}
// />
// </IconButton>
// }
disablePadding
>
<ListItemButton disableRipple role={undefined} dense>
<ListItemIcon>
<Checkbox
edge="start"
checked={checked1}
tabIndex={-1}
disableRipple
disabled={true}
sx={{
"&.Mui-checked": {
color: "white", // Customize the color when checked
},
"& .MuiSvgIcon-root": {
color: "white",
},
}}
/>
</ListItemIcon>
<ListItemText primary={`Have at least 6 QORT in your wallet`} />
</ListItemButton>
</ListItem>
<ListItem
// secondaryAction={
// <IconButton edge="end" aria-label="comments">
// <InfoIcon
// sx={{
// color: "white",
// }}
// />
// </IconButton>
// }
disablePadding
>
<ListItemButton disableRipple role={undefined} dense>
<ListItemIcon>
<Checkbox
edge="start"
checked={checked2}
tabIndex={-1}
disableRipple
disabled={true}
sx={{
"&.Mui-checked": {
color: "white", // Customize the color when checked
},
"& .MuiSvgIcon-root": {
color: "white",
},
}}
/>
</ListItemIcon>
<ListItemText primary={`Register a name`} />
</ListItemButton>
</ListItem>
<ListItem
// secondaryAction={
// <IconButton edge="end" aria-label="comments">
// <InfoIcon
// sx={{
// color: "white",
// }}
// />
// </IconButton>
// }
disablePadding
>
<ListItemButton disableRipple role={undefined} dense>
<ListItemIcon>
<Checkbox
edge="start"
checked={checked3}
tabIndex={-1}
disableRipple
disabled={true}
sx={{
"&.Mui-checked": {
color: "white", // Customize the color when checked
},
"& .MuiSvgIcon-root": {
color: "white",
},
}}
/>
</ListItemIcon>
<ListItemText primary={`Join a group`} />
</ListItemButton>
</ListItem>
</List>
</Box>
);
};

View File

@@ -0,0 +1,206 @@
import { Box, Button, ListItem, ListItemButton, ListItemText, Popover, Typography } from '@mui/material';
import React, { useContext, useEffect, useRef, useState } from 'react'
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import { MyContext, getBaseApiReact } from '../../App';
import { LoadingButton } from '@mui/lab';
import { getBaseApi, getFee } from '../../background';
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
const getGroupInfo = async (groupId)=> {
const response = await fetch(`${getBaseApiReact()}/groups/` + groupId);
const groupData = await response.json();
if (groupData) {
return groupData
}
}
export const getGroupNames = async (listOfGroups) => {
let groups = [];
if (listOfGroups && Array.isArray(listOfGroups)) {
for (const group of listOfGroups) {
const groupInfo = await getGroupInfo(group.groupId);
if (groupInfo) {
groups.push({ ...group, ...groupInfo });
}
}
}
return groups;
}
export const UserListOfInvites = ({myAddress, setInfoSnack, setOpenSnack}) => {
const {txList, setTxList, show} = useContext(MyContext)
const [invites, setInvites] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to
const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Track which list item has the popover open
const listRef = useRef();
const getRequests = async () => {
try {
const response = await fetch(`${getBaseApiReact()}/groups/invites/${myAddress}/?limit=0`);
const inviteData = await response.json();
const resMoreData = await getGroupNames(inviteData)
setInvites(resMoreData);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
getRequests();
}, []);
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const handleJoinGroup = async (groupId, groupName)=> {
try {
const fee = await getFee('JOIN_GROUP')
await show({
message: "Would you like to perform an JOIN_GROUP transaction?" ,
publishFee: fee.fee + ' QORT'
})
setIsLoading(true);
await new Promise((res, rej)=> {
chrome.runtime.sendMessage({ action: "joinGroup", payload: {
groupId,
}}, (response) => {
if (!response?.error) {
setTxList((prev)=> [{
...response,
type: 'joined-group',
label: `Joined Group ${groupName}: awaiting confirmation`,
labelDone: `Joined Group ${groupName}: success !`,
done: false,
groupId,
}, ...prev])
res(response)
setInfoSnack({
type: "success",
message: "Successfully requested to join group. It may take a couple of minutes for the changes to propagate",
});
setOpenSnack(true);
handlePopoverClose();
return
}
setInfoSnack({
type: "error",
message: response?.error,
});
setOpenSnack(true);
rej(response.error)
});
})
} catch (error) {
} finally {
setIsLoading(false);
}
}
const rowRenderer = ({ index, key, parent, style }) => {
const invite = invites[index];
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ measure }) => (
<div style={style} onLoad={measure}>
<ListItem disablePadding>
<Popover
open={openPopoverIndex === index}
anchorEl={popoverAnchor}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
style={{ marginTop: "8px" }}
>
<Box
sx={{
width: "325px",
height: "250px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
padding: "10px",
}}
>
<Typography>Join {invite?.groupName}</Typography>
<LoadingButton
loading={isLoading}
loadingPosition="start"
variant="contained" onClick={()=> handleJoinGroup(invite?.groupId, invite?.groupName)}>Join group</LoadingButton>
</Box>
</Popover>
<ListItemButton onClick={(event) => handlePopoverOpen(event, index)}>
<ListItemText primary={invite?.groupName} secondary={invite?.description} />
</ListItemButton>
</ListItem>
</div>
)}
</CellMeasurer>
);
};
return (
<div>
<p>Invite list</p>
<div style={{ position: 'relative', height: '500px', width: '600px', display: 'flex', flexDirection: 'column', flexShrink: 1 }}>
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={invites.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import React, { useEffect, useRef } from 'react';
import { getBaseApiReactSocket } from '../../App';
export const WebSocketActive = ({ myAddress }) => {
const socketRef = useRef(null); // WebSocket reference
const timeoutIdRef = useRef(null); // Timeout ID reference
const groupSocketTimeoutRef = useRef(null); // Group Socket Timeout reference
const forceCloseWebSocket = () => {
if (socketRef.current) {
console.log('Force closing the WebSocket');
clearTimeout(timeoutIdRef.current);
clearTimeout(groupSocketTimeoutRef.current);
socketRef.current.close(1000, 'forced');
socketRef.current = null;
}
};
useEffect(() => {
if (!myAddress) return; // Only proceed if myAddress is set
if (!window?.location?.href?.includes("?main=true")) return;
const pingHeads = () => {
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 = async () => {
forceCloseWebSocket(); // Ensure we close any existing connection
const currentAddress = myAddress;
try {
const socketLink = `${getBaseApiReactSocket()}/websockets/chat/active/${currentAddress}?encoding=BASE64`;
socketRef.current = new WebSocket(socketLink);
socketRef.current.onopen = () => {
console.log('WebSocket connection opened');
setTimeout(pingHeads, 50); // Initial ping
};
socketRef.current.onmessage = (e) => {
try {
if (e.data === 'pong') {
clearTimeout(timeoutIdRef.current);
groupSocketTimeoutRef.current = setTimeout(pingHeads, 45000); // Ping every 45 seconds
} else {
const data = JSON.parse(e.data);
const filteredGroups = data.groups?.filter(item => item?.groupId !== 0) || [];
const sortedGroups = filteredGroups.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
const sortedDirects = (data?.direct || []).filter(item =>
item?.name !== 'extension-proxy' && item?.address !== 'QSMMGSgysEuqDCuLw3S4cHrQkBrh3vP3VH'
).sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
chrome.runtime.sendMessage({
action: 'handleActiveGroupDataFromSocket',
payload: {
groups: sortedGroups,
directs: sortedDirects,
},
});
}
} catch (error) {
console.error('Error parsing onmessage data:', error);
}
};
socketRef.current.onclose = (event) => {
clearTimeout(groupSocketTimeoutRef.current);
clearTimeout(timeoutIdRef.current);
console.warn(`WebSocket closed: ${event.reason || 'unknown reason'}`);
if (event.reason !== 'forced' && event.code !== 1000) {
setTimeout(() => initWebsocketMessageGroup(), 10000); // Retry after 10 seconds
}
};
socketRef.current.onerror = (error) => {
console.error('WebSocket error:', error);
clearTimeout(groupSocketTimeoutRef.current);
clearTimeout(timeoutIdRef.current);
if (socketRef.current) {
socketRef.current.close();
}
};
} catch (error) {
console.error('Error initializing WebSocket:', error);
}
};
initWebsocketMessageGroup(); // Initialize WebSocket on component mount
return () => {
forceCloseWebSocket(); // Clean up WebSocket on component unmount
};
}, [myAddress]);
return null;
};

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import Button from '@mui/material/Button';
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
export const LoadingSnackbar = ({open, info}) => {
return (
<div>
<Snackbar anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} open={open}>
<Alert
severity="info"
variant="filled"
sx={{ width: '100%' }}
>
{info?.message}
</Alert>
</Snackbar>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import * as React from 'react';
import Button from '@mui/material/Button';
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
export const CustomizedSnackbars = ({open, setOpen, info, setInfo}) => {
const handleClose = (
event?: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason,
) => {
if (reason === 'clickaway') {
return;
}
setOpen(false);
setInfo(null)
};
return (
<div>
<Snackbar anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} open={open} autoHideDuration={6000} onClose={handleClose}>
<Alert
onClose={handleClose}
severity={info?.type}
variant="filled"
sx={{ width: '100%' }}
>
{info?.message}
</Alert>
</Snackbar>
</div>
);
}

View File

@@ -0,0 +1,175 @@
import { List, ListItemButton, ListItemIcon } from "@mui/material";
import React, { useContext, useEffect, useRef } from "react";
import ListItemText from "@mui/material/ListItemText";
import Collapse from "@mui/material/Collapse";
import InboxIcon from "@mui/icons-material/MoveToInbox";
import ExpandLess from "@mui/icons-material/ExpandLess";
import ExpandMore from "@mui/icons-material/ExpandMore";
import StarBorder from "@mui/icons-material/StarBorder";
import PendingIcon from "@mui/icons-material/Pending";
import TaskAltIcon from "@mui/icons-material/TaskAlt";
import { MyContext, getBaseApiReact } from "../../App";
import { getBaseApi } from "../../background";
export const TaskManger = ({getUserInfo}) => {
const { txList, setTxList, memberGroups } = useContext(MyContext);
const [open, setOpen] = React.useState(true);
const handleClick = () => {
setOpen(!open);
};
const intervals = useRef({})
const getStatus = ({signature}, callback?: any) =>{
let stop = false
const getAnswer = async () => {
const getTx = async () => {
const url = `${getBaseApiReact()}/transactions/signature/${signature}`
const res = await fetch(url)
return await res.json()
}
if (!stop) {
stop = true
try {
const txTransaction = await getTx()
if (!txTransaction.error && txTransaction.signature) {
await new Promise((res)=> {
setTimeout(() => {
res(null)
}, 300000);
})
setTxList((prev)=> {
let previousData = [...prev];
const findTxWithSignature = previousData.findIndex((tx)=> tx.signature === signature)
if(findTxWithSignature !== -1){
previousData[findTxWithSignature].done = true;
return previousData
}
return previousData
})
if(callback){
callback(true)
}
clearInterval(intervals.current[signature])
}
} catch (error) { }
stop = false
}
}
intervals.current[signature] = setInterval(getAnswer, 120000)
}
useEffect(() => {
setTxList((prev) => {
let previousData = [...prev];
memberGroups.forEach((group) => {
const findGroup = txList.findIndex(
(tx) => tx?.type === "joined-group" && tx?.groupId === group.groupId
);
if (findGroup !== -1 && !previousData[findGroup]?.done ) {
// add notification
previousData[findGroup].done = true;
}
});
memberGroups.forEach((group) => {
const findGroup = txList.findIndex(
(tx) => tx?.type === "created-group" && tx?.groupName === group.groupName
);
if (findGroup !== -1 && !previousData[findGroup]?.done ) {
// add notification
previousData[findGroup].done = true;
}
});
prev.forEach((tx, index)=> {
if(tx?.type === "leave-group" && memberGroups.findIndex(
(group) => tx?.groupId === group.groupId
) === -1){
previousData[index].done = true;
}
})
prev.forEach((tx, index)=> {
if(tx?.type === "created-common-secret" && tx?.signature && !tx.done){
if(intervals.current[tx.signature]) return
getStatus({signature: tx.signature})
}
})
prev.forEach((tx, index)=> {
if(tx?.type === "joined-group-request" && tx?.signature && !tx.done){
if(intervals.current[tx.signature]) return
getStatus({signature: tx.signature})
}
})
prev.forEach((tx, index)=> {
if(tx?.type === "register-name" && tx?.signature && !tx.done){
if(intervals.current[tx.signature]) return
getStatus({signature: tx.signature}, getUserInfo)
}
})
return previousData;
});
}, [memberGroups, getUserInfo]);
if (txList?.length === 0 || txList.filter((item) => !item?.done).length === 0) return null;
return (
<List
sx={{ width: "100%", maxWidth: 360, bgcolor: "background.paper" }}
component="nav"
aria-labelledby="nested-list-subheader"
>
<ListItemButton onClick={handleClick}>
<ListItemIcon>
{txList.find((item) => !item.done) ? (
<PendingIcon sx={{
color: 'white'
}} />
) : (
<TaskAltIcon sx={{
color: 'white'
}} />
)}
</ListItemIcon>
<ListItemText primary="Ongoing Transactions" />
{open ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={open} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{txList.map((item) => {
return (
<ListItemButton key={item?.signature} sx={{ pl: 4 }}>
<ListItemText primary={item?.done ? item.labelDone : item.label} />
</ListItemButton>
);
})}
</List>
</Collapse>
</List>
);
};