qortal-mobile/src/components/Chat/GroupAnnouncements.tsx

666 lines
18 KiB
TypeScript

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, isMobile, pauseAllQueues, resumeAllQueues } 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);
return
}
rej(response.error);
}
);
});
};
export const getTempPublish = async () => {
return new Promise((res, rej) => {
chrome?.runtime?.sendMessage(
{
action: "getTempPublish",
payload: {
},
},
(response) => {
if (!response?.error) {
res(response);
return
}
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 [isFocusedParent, setIsFocusedParent] = useState(false);
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();
if(isMobile){
setTimeout(() => {
editorRef.current?.chain().blur().run();
setIsFocusedParent(false)
}, 200);
}
}
};
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 {
pauseAllQueues()
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(false, true);
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) {
if(!error) return
setInfoSnack({
type: "error",
message: error,
});
setOpenSnack(true)
} finally {
resumeAllQueues()
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 && !hide) {
getAnnouncements(selectedGroup);
hasInitialized.current = true
}
}, [selectedGroup, secretKey, hide]);
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 || hide) return
checkNewMessagesFunc()
return () => {
if (interval?.current) {
clearInterval(interval.current)
}
}
}, [checkNewMessagesFunc, hide])
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: isMobile ? '100%' : "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: isMobile ? '100%' : "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: isMobile ? '8px' : "25px",
fontSize: isMobile ? '16px' : "20px",
gap: '20px',
alignItems: 'center'
}}
>
<CampaignIcon sx={{
fontSize: isMobile ? '16px' : '30px'
}} />
Group Announcements
</Box>
<Spacer height={isMobile ? "10px" : "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}
myName={myName}
/>
{isAdmin && (
<div
style={{
// position: 'fixed',
// bottom: '0px',
backgroundColor: "#232428",
minHeight: isMobile ? "0px" : "150px",
maxHeight: isMobile ? "auto" : "400px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
width: "100%",
boxSizing: "border-box",
padding: isMobile ? "10px": "20px",
position: isFocusedParent ? 'fixed' : 'relative',
bottom: isFocusedParent ? '0px' : 'unset',
top: isFocusedParent ? '0px' : 'unset',
zIndex: isFocusedParent ? 5 : 'unset',
flexShrink: 0
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
flexGrow: isMobile && 1,
overflow: "auto",
// height: '100%',
}}
>
<Tiptap
setEditorRef={setEditorRef}
onEnter={publishAnnouncement}
disableEnter
maxHeightOffset="40px"
isFocusedParent={isFocusedParent} setIsFocusedParent={setIsFocusedParent}
/>
</div>
<Box sx={{
display: 'flex',
width: '100&',
gap: '10px',
justifyContent: 'center',
flexShrink: 0,
position: 'relative',
}}>
{isFocusedParent && (
<CustomButton
onClick={()=> {
if(isSending) return
setIsFocusedParent(false)
clearEditorContent()
// Unfocus the editor
}}
style={{
marginTop: 'auto',
alignSelf: 'center',
cursor: isSending ? 'default' : 'pointer',
background: 'red',
flexShrink: 0,
padding: isMobile && '5px',
fontSize: isMobile && '14px',
}}
>
{` Close`}
</CustomButton>
)}
<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,
padding: isMobile && '5px',
fontSize: isMobile && '14px',
}}
>
{isSending && (
<CircularProgress
size={18}
sx={{
position: "absolute",
top: "50%",
left: "50%",
marginTop: "-12px",
marginLeft: "-12px",
color: "white",
}}
/>
)}
{` Publish Announcement`}
</CustomButton>
</Box>
</div>
)}
<CustomizedSnackbars open={openSnack} setOpen={setOpenSnack} info={infoSnack} setInfo={setInfoSnack} />
<LoadingSnackbar
open={isLoading}
info={{
message: "Loading announcements... please wait.",
}}
/>
</div>
);
};