mirror of
https://github.com/Qortal/Qortal-Hub.git
synced 2025-07-23 04:36:52 +00:00
version 2 - beta
This commit is contained in:
344
src/components/Chat/AnnouncementDiscussion.tsx
Normal file
344
src/components/Chat/AnnouncementDiscussion.tsx
Normal 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>
|
||||
);
|
||||
};
|
167
src/components/Chat/AnnouncementItem.tsx
Normal file
167
src/components/Chat/AnnouncementItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
96
src/components/Chat/AnnouncementList.tsx
Normal file
96
src/components/Chat/AnnouncementList.tsx
Normal 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>
|
||||
);
|
||||
};
|
56
src/components/Chat/ChatContainer.tsx
Normal file
56
src/components/Chat/ChatContainer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
305
src/components/Chat/ChatDirect.tsx
Normal file
305
src/components/Chat/ChatDirect.tsx
Normal 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>
|
||||
)
|
||||
}
|
377
src/components/Chat/ChatGroup.tsx
Normal file
377
src/components/Chat/ChatGroup.tsx
Normal 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>
|
||||
)
|
||||
}
|
144
src/components/Chat/ChatList.tsx
Normal file
144
src/components/Chat/ChatList.tsx
Normal 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>
|
||||
);
|
||||
};
|
79
src/components/Chat/CreateCommonSecret.tsx
Normal file
79
src/components/Chat/CreateCommonSecret.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
59
src/components/Chat/CustomImage.ts
Normal file
59
src/components/Chat/CustomImage.ts
Normal 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;
|
607
src/components/Chat/GroupAnnouncements.tsx
Normal file
607
src/components/Chat/GroupAnnouncements.tsx
Normal 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>
|
||||
);
|
||||
};
|
52
src/components/Chat/GroupForum.tsx
Normal file
52
src/components/Chat/GroupForum.tsx
Normal 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>
|
||||
);
|
||||
};
|
66
src/components/Chat/MessageDisplay.tsx
Normal file
66
src/components/Chat/MessageDisplay.tsx
Normal 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
93
src/components/Chat/MessageItem.tsx
Normal file
93
src/components/Chat/MessageItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
63
src/components/Chat/ResizableImage.tsx
Normal file
63
src/components/Chat/ResizableImage.tsx
Normal 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;
|
292
src/components/Chat/TipTap.tsx
Normal file
292
src/components/Chat/TipTap.tsx
Normal 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
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
121
src/components/Chat/styles.css
Normal file
121
src/components/Chat/styles.css
Normal 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%;
|
||||
}
|
474
src/components/Group/AddGroup.tsx
Normal file
474
src/components/Group/AddGroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
272
src/components/Group/AddGroupList.tsx
Normal file
272
src/components/Group/AddGroupList.tsx
Normal 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>
|
||||
);
|
||||
};
|
45
src/components/Group/Forum/DisplayHtml.tsx
Normal file
45
src/components/Group/Forum/DisplayHtml.tsx
Normal 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>
|
||||
);
|
||||
};
|
777
src/components/Group/Forum/GroupMail.tsx
Normal file
777
src/components/Group/Forum/GroupMail.tsx
Normal 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>
|
||||
);
|
||||
};
|
799
src/components/Group/Forum/Mail-styles.ts
Normal file
799
src/components/Group/Forum/Mail-styles.ts
Normal 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)",
|
||||
},
|
||||
}));
|
554
src/components/Group/Forum/NewThread.tsx
Normal file
554
src/components/Group/Forum/NewThread.tsx
Normal 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>
|
||||
);
|
||||
};
|
129
src/components/Group/Forum/ReadOnlySlate.tsx
Normal file
129
src/components/Group/Forum/ReadOnlySlate.tsx
Normal 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;
|
57
src/components/Group/Forum/ReusableModal.tsx
Normal file
57
src/components/Group/Forum/ReusableModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
224
src/components/Group/Forum/ShowMessageWithoutModal.tsx
Normal file
224
src/components/Group/Forum/ShowMessageWithoutModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
39
src/components/Group/Forum/TextEditor.tsx
Normal file
39
src/components/Group/Forum/TextEditor.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
329
src/components/Group/Forum/Thread copy.tsx
Normal file
329
src/components/Group/Forum/Thread copy.tsx
Normal 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>
|
||||
)
|
||||
}
|
663
src/components/Group/Forum/Thread.tsx
Normal file
663
src/components/Group/Forum/Thread.tsx
Normal 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>
|
||||
);
|
||||
};
|
71
src/components/Group/Forum/texteditor.css
Normal file
71
src/components/Group/Forum/texteditor.css
Normal 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;
|
||||
}
|
1943
src/components/Group/Group.tsx
Normal file
1943
src/components/Group/Group.tsx
Normal file
File diff suppressed because it is too large
Load Diff
114
src/components/Group/GroupInvites.tsx
Normal file
114
src/components/Group/GroupInvites.tsx
Normal 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>
|
||||
);
|
||||
};
|
170
src/components/Group/GroupJoinRequests.tsx
Normal file
170
src/components/Group/GroupJoinRequests.tsx
Normal 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>
|
||||
);
|
||||
};
|
108
src/components/Group/InviteMember.tsx
Normal file
108
src/components/Group/InviteMember.tsx
Normal 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>
|
||||
);
|
||||
};
|
188
src/components/Group/ListOfBans.tsx
Normal file
188
src/components/Group/ListOfBans.tsx
Normal 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>
|
||||
);
|
||||
}
|
189
src/components/Group/ListOfInvites.tsx
Normal file
189
src/components/Group/ListOfInvites.tsx
Normal 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>
|
||||
);
|
||||
}
|
189
src/components/Group/ListOfJoinRequests.tsx
Normal file
189
src/components/Group/ListOfJoinRequests.tsx
Normal 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>
|
||||
);
|
||||
}
|
385
src/components/Group/ListOfMembers.tsx
Normal file
385
src/components/Group/ListOfMembers.tsx
Normal 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;
|
138
src/components/Group/ListOfThreadPostsWatched.tsx
Normal file
138
src/components/Group/ListOfThreadPostsWatched.tsx
Normal 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>
|
||||
);
|
||||
};
|
316
src/components/Group/ManageMembers.tsx
Normal file
316
src/components/Group/ManageMembers.tsx
Normal 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>
|
||||
);
|
||||
};
|
166
src/components/Group/ThingsToDoInitial.tsx
Normal file
166
src/components/Group/ThingsToDoInitial.tsx
Normal 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>
|
||||
);
|
||||
};
|
206
src/components/Group/UserListOfInvites.tsx
Normal file
206
src/components/Group/UserListOfInvites.tsx
Normal 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>
|
||||
);
|
||||
}
|
109
src/components/Group/WebsocketActive.tsx
Normal file
109
src/components/Group/WebsocketActive.tsx
Normal 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;
|
||||
};
|
21
src/components/Snackbar/LoadingSnackbar.tsx
Normal file
21
src/components/Snackbar/LoadingSnackbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
38
src/components/Snackbar/Snackbar.tsx
Normal file
38
src/components/Snackbar/Snackbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
175
src/components/TaskManager/TaskManger.tsx
Normal file
175
src/components/TaskManager/TaskManger.tsx
Normal 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>
|
||||
);
|
||||
};
|
Reference in New Issue
Block a user