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%;
|
||||
}
|
Reference in New Issue
Block a user