const contract = props.contract || "chat.ws-protocol-63"; const encryptionUrl = props.encryptionUrl || "https://cali-encryption.euw3.staging.gcp.calimero.network/key"; const componentOwnerId = props.componentOwnerId || "calimera.testnet"; const ChatContainer = styled.div` background-color: #0e0e10; padding-top: 27px; padding-left: 4px; `; const PageContainer = styled.div` width: 100%; height: 100vh; background-color: #0e0e10; `; State.init({ bootstraping: true, loggedIn: false, organizationName: "", openCreateChannel: false, channelList: [], selectedChannel: undefined, settingsId: -1, directMessagesOpen: true, usersList: [], unread: {}, selectedDM: context.accountId, channelDetailsOpen: false, addNewUserClick: false, channelUserList: [], chatMessages: [], name: "", inputId: 0, aboutSelected: true, channelMeta: null, functionLoader: false, img: null, key: null, showThread: false, threadId: -1, }); const openThread = useCallback(() => State.update({ showThread: true }), []); const closeThread = useCallback(() => { State.update({ threadId: -1, showThread: false }); }, []); const setThread = useCallback( (messageId) => State.update({ threadId: messageId, showThread: true }), [] ); console.log("Rendering Main", state); const updateMemberList = () => Near.asyncCalimeroView(contract, "get_members").then((m) => { State.update({ usersList: m }); return m; }); const updateChannelMemberList = useCallback( (id) => { const usedId = id || state.selectedChannel; if (usedId < 0) { return; } return Near.asyncCalimeroView( contract, "get_members", { group: state.channelList[usedId] }, undefined, true ).then((m) => State.update({ channelUserList: m })); }, [contract, state.selectedChannel, state.channelList] ); const updateUnread = () => Near.asyncCalimeroView( contract, "unread_messages", { account: context.accountId }, undefined, true ).then((u) => State.update({ unread: u })); const updateChannelList = () => Near.asyncCalimeroView( contract, "get_groups", { account: context.accountId }, undefined, true ).then((c) => State.update({ channelList: c })); if (state.selectedChannel >= 0) { Near.asyncCalimeroView( contract, "channel_info", { group: state.channelList[state.selectedChannel] }, undefined, true ).then((m) => State.update({ channelMeta: m })); } //HELPER FUNCTIONS function parseHexString(hexString) { const result = []; while (hexString.length >= 2) { result.push(parseInt(hexString.substring(0, 2), 16)); hexString = hexString.substring(2, hexString.length); } return result; } function toHexString(byteArray) { const result = ""; for (let byte of byteArray) { result += ("0" + (byte & 0xff).toString(16)).slice(-2); } return result; } function encrypt(text, key) { const nonce = Crypto.randomBytes(16); const cipher = Crypto.createCipheriv( "aes-256-cbc", parseHexString(key), nonce ); let encrypted = cipher.update(text, "utf8", "base64"); encrypted += cipher.final("base64"); return { text: encrypted, nonce: toHexString(nonce) }; } //CHANGE FUNCTIONS const onChannelSelected = useCallback( (id) => { State.update({ selectedChannel: id, threadId: -1, showThread: false, settingsId: -1, selectedDM: undefined, }); updateChannelMemberList(id); }, [updateChannelMemberList] ); const onDMSelected = useCallback((id) => { State.update({ selectedDM: id, selectedChannel: undefined, threadId: -1, showThread: false, settingsId: -1, selectedDM: id, }); }, []); const onChangeChannelDialog = useCallback( () => (open) => { State.update({ openCreateChannel: open }); }, [] ); const onChangeChannelSettings = useCallback( () => (id) => State.update({ settingsId: id }), [] ); const openChannelSettings = useCallback(() => { return (id) => { State.update({ settingsId: id }); }; }, []); const onToggleDMs = useCallback(() => { State.update({ directMessagesOpen: !state.directMessagesOpen }); }, [state.directMessagesOpen]); const channelDetailsClick = useCallback( () => State.update({ channelDetailsOpen: !state.channelDetailsOpen }), [state.channelDetailsOpen] ); const onAddNewUser = useCallback( (open) => { const action = open === undefined ? !state.addNewUserClick : open; State.update({ addNewUserClick: action }); }, [state.addNewUserClick] ); const handleClosePopupUser = () => onAddNewUser(false); const onChangeName = ({ target }) => { State.update({ name: target.value }); }; const updateInputId = (id) => { State.update({ inputId: id }); }; const sendMessage = useCallback( (message, img) => { if (!message) { return; } let params = {}; if (state.selectedDM) { params = { account: state.selectedDM }; } else { params = { group: state.channelList[state.selectedChannel] }; } params.message = message; if (img) { params.message = params.message + `$?$https://ipfs.near.social/ipfs/${img.cid}`; } const encrypted = encrypt(params.message, state.key); params.message = encrypted.text; params.nonce = encrypted.nonce; params.timestamp = Date.now(); if (state.showThread && state.threadId !== -1) { params.parent_message = state.threadId; } const newMessage = { sender: context.accountId, text: params.message, nonce: params.nonce, timestamp: params.timestamp, thread: [], parent_thread: params.parent_message, }; const selectedChannel = state.selectedDM ? state.selectedDM : state.selectedChannel; let newMessagesArray = []; const storageMessages = Storage.privateGet( "tempMessages" + contract + selectedChannel ); if (storageMessages && JSON.parse(storageMessages).length > 0) { newMessagesArray = JSON.parse(storageMessages); newMessagesArray.push(newMessage); } else { newMessagesArray.push(newMessage); } const jsonStringArray = JSON.stringify(newMessagesArray); Storage.privateSet( "tempMessages" + contract + selectedChannel, jsonStringArray ); Near.fakCalimeroCall(contract, "send_message", params); setMessages(); updateInputId(Math.random().toString(36)); }, [ state.key, state.selectedDM, state.channelList, state.selectedChannel, state.showThread, state.threadId, context.accountId, ] ); const joinCurb = () => { Near.requestCalimeroFak(contract); }; const handleLeaveChannel = () => { const channel = state.channelList[state.selectedChannel]; State.update({ selectedChannel: -1, selectedDM: context.accountId }); Near.fakCalimeroCall(contract, "leave_group", { group: channel, account: context.accountId, }).then(() => { handleCloseSettingsPopup(); }); }; const handleCreateChannel = () => { State.update({ functionLoader: true }); Near.fakCalimeroCall(contract, "create_group", { group: { name: state.name }, }).then(() => { State.update({ functionLoader: false }); handleClosePopup(); }); }; const handleInviteUser = () => { State.update({ functionLoader: true }); Near.fakCalimeroCall(contract, "group_invite", { group: state.channelList[state.selectedChannel], account: state.name, }).then(() => { onAddNewUser(false); State.update({ functionLoader: false }); }); }; const handleCloseSettingsPopup = () => { swithTab(true); State.update({ channelDetailsOpen: false }); }; const handleClosePopup = () => onChangeChannelDialog(false); const swithTab = (selected) => State.update({ aboutSelected: selected }); const addMemberFromSettings = () => { State.update({ channelDetailsOpen: false }); onAddNewUser(true); }; const addMessageReaction = useCallback( (params) => { const reactedMessage = state.chatMessages.filter( (message) => message.id === params.message_id )[0]; if (reactedMessage.reactions) { reactedMessage.reactions[params.reaction] = [context.accountId]; } else { reactedMessage.reactions = {}; reactedMessage.reactions[params.reaction] = [context.accountId]; } const selectedChannel = state.selectedDM ? state.selectedDM : state.selectedChannel; const storageReactions = JSON.parse( Storage.privateGet("storageReactions" + contract + selectedChannel) ); if (storageReactions) { storageReactions.push(reactedMessage); Storage.privateSet( "storageReactions" + contract + selectedChannel, JSON.stringify(storageReactions) ); } else { Storage.privateSet( "storageReactions" + contract + selectedChannel, JSON.stringify([reactedMessage]) ); } Near.fakCalimeroCall(contract, "toggle_reaction", params); //const reaction }, [ state.chatMessages, state.selectedDM, state.selectedChannel, context.accountId, ] ); const openMemberList = useCallback(() => { swithTab(false); State.update({ channelDetailsOpen: true }); }, []); const isMember = (accountId, members) => { return (members || state.usersList) .map((user) => user.id) .includes(accountId); }; const verifyKey = () => { Near.hasValidCalimeroFak(contract).then((result) => { State.update({ bootstraping: false, loggedIn: result }); Near.asyncCalimeroView(contract, "get_name").then((n) => State.update({ organizationName: n }) ); if (result) { updateMemberList().then((members) => { if (!isMember(context.accountId, members)) { Near.fakCalimeroCall(contract, "join"); } else { ping(); } }); updateChannelList(); } }); }; const readMessageParams = state.channelList[state.selectedChannel] ? { group: state.channelList[state.selectedChannel], } : { account: state.selectedDM, }; const readMessage = useCallback( (id) => id && Near.fakCalimeroCall(contract, "read_message", { message_id: id, ...readMessageParams, }), [readMessageParams] ); if (state.bootstraping) { verifyKey(); } const ping = () => Near.fakCalimeroCall(contract, "ping"); const fetchKey = () => { if (state.loggedIn) { const nonce = toHexString(Crypto.randomBytes(16)); Calimero.sign( contract, Buffer.from(context.accountId + "|" + nonce) ).then((signature) => { const keyBody = { from: context.accountId, }; if (state.selectedDM) { keyBody.to = state.selectedDM; } else { keyBody.group = { name: state.selectedChannel}; } keyBody.nonce = nonce; keyBody.signature = toHexString(signature.signature); asyncFetch(encryptionUrl, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(keyBody), }).then((keyData) => { State.update({ key: keyData.body.key }); }); }); } }; function arraysAreEqual(arr1, arr2) { if (arr1.length !== arr2.length) { return false; } return arr1.every((value, index) => value === arr2[index]); } const setMessages = () => new Promise((resolve, reject) => { fetchKey(); try { let messages = []; const selectedConversation = state.selectedDM ? state.selectedDM : state.selectedChannel; Near.asyncCalimeroView( contract, "get_messages", state.selectedDM ? { accounts: [context.accountId, selectedConversation], } : { group: { name: selectedConversation, }, }, undefined, true ).then((m) => { messages = m; const storageMessages = Storage.privateGet( "tempMessages" + contract + selectedConversation ); if (storageMessages && JSON.parse(storageMessages).length > 0) { const storageArray = JSON.parse(storageMessages); const filteredStorageArray = storageArray.filter((storageItem) => { return ( !storageItem.parent_thread && !messages.some((blockchainItem) => { return ( blockchainItem.nonce === storageItem.nonce && blockchainItem.timestamp === storageItem.timestamp && !storageItem.parent_thread ); }) ); }); const combinedArray = [...messages, ...filteredStorageArray]; const threadsArray = []; messages = combinedArray; // TODO figure out why does this trigger loop // const messagesWithThread = messages.map((msg) => { // const newReply = storageArray.filter( // (stMsg) => stMsg.parent_thread === msg.id // ); // if (newReply.length > 0) { // const existingThread = msg.thread || []; // const newReplyFiltered = newReply.filter((newReplyMsg) => { // return !existingThread.some( // (item) => // item.timestamp === newReplyMsg.timestamp && // item.nonce === newReplyMsg.nonce // ); // }); // threadsArray = [...threadsArray, ...newReplyFiltered]; // const combinedThreadArray = [ // ...existingThread, // ...newReplyFiltered, // ]; // return { // ...msg, // thread: combinedThreadArray, // }; // } // return msg; // }); // messages = messagesWithThread; Storage.privateSet( "tempMessages" + contract + selectedConversation, JSON.stringify([...threadsArray, ...filteredStorageArray]) ); } const storageReactions = JSON.parse( Storage.privateGet( "storageReactions" + contract + selectedConversation ) ); if (storageReactions && storageReactions.length > 0) { const filteredStorageReactions = storageReactions.filter( (storageItem) => { const matchingMessage = messages.find( (message) => message.id === storageItem.id ); if (matchingMessage) { const storageReactionsKeys = Object.keys(storageItem.reactions); const matchingMessageReactionsKeys = Object.keys( matchingMessage.reactions ); if ( storageReactionsKeys.length !== matchingMessageReactionsKeys.length ) { return true; } for (const key of storageReactionsKeys) { const storageReactionsArray = storageItem.reactions[key]; const matchingMessageReactionsArray = matchingMessage.reactions[key]; if ( !arraysAreEqual( storageReactionsArray, matchingMessageReactionsArray ) ) { return true; } } return false; } return true; } ); Storage.privateSet( "storageReactions" + contract + selectedConversation, filteredStorageReactions ); storageReactions.forEach((reaction) => { messages.forEach((message) => { if (reaction.id === message.id) { message.reactions = reaction.reactions; } }); }); } State.update({ chatMessages: messages }); console.log("Messages", messages.length, keyBody); resolve(messages); }); } catch (e) { console.log(e); Storage.privateSet("tempMessages" + contract + selectedChannel, ""); reject(); } }); useEffect(() => { updateMemberList(); updateChannelMemberList(); updateChannelList(); updateUnread(); setMessages(); }, [state.selectedChannel, state.selectedDM]); useCache((ping, "ping", { subscribe: true })); useCache(setMessages, "messages", { subscribe: true }); useCache(updateUnread, "unread", { subscribe: true }); return ( <PageContainer> {context.accountId ? ( <> {state.bootstraping ? ( <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Popups.LoadingPopup`} props={{ componentOwnerId }} /> ) : ( <> {state.loggedIn && isMember(context.accountId) ? ( <> {state.openCreateChannel && ( <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Popups.InputPopup`} props={{ componentOwnerId, title: "Create new Channel", placeholder: "# channel name", buttonText: "Create", handleClosePopup: handleClosePopup, onChange: onChangeName, handleClickEvent: handleCreateChannel, functionLoader: state.functionLoader, }} /> )} {state.addNewUserClick && ( <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Popups.InputPopup`} props={{ componentOwnerId, title: `Invite user to ${ state.channelList[state.selectedChannel].name }`, placeholder: "account_id", buttonText: "Invite", handleClosePopup: handleClosePopupUser, onChange: onChangeName, handleClickEvent: handleInviteUser, functionLoader: state.functionLoader, }} /> )} {state.channelDetailsOpen && ( <> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Settings.DetailsContainer`} props={{ componentOwnerId, handleCloseSettingsPopup: handleCloseSettingsPopup, channelName: state.channelList[state.selectedChannel].name, selectedTab: state.aboutSelected, userCount: state.channelUserList.length, onSwitch: () => swithTab(!state.aboutSelected), dateCreated: state.channelMeta.createdAt, manager: state.channelMeta.createdBy, handleLeaveChannel: handleLeaveChannel, userList: state.channelUserList, addMember: () => addMemberFromSettings(), aboutSelected: state.aboutSelected, }} /> </> )} <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Navbar.CurbNavbar`} props={{ componentOwnerId, organizationName: state.organizationName, channelSelected: state.selectedDM ? state.selectedDM : state.channelList[state.selectedChannel].name, channelDetailsClick, channelDetailsOpen: state.channelDetailsOpen, channelUserList: state.selectedDM ? [] : state.channelUserList, isDMSelected: !!state.selectedDM, onAddNewUser, openMemberList, }} /> <div className="d-flex"> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.SideSelector.SideSelector`} props={{ componentOwnerId, onChangeChannelDialog, onChannelSelected, onChangeChannelSettings, onDMSelected, channelList: state.channelList, selectedChannel: state.selectedChannel, onToggleDMs, directMessagesOpen: state.directMessagesOpen, usersList: state.usersList, selectedDM: state.selectedDM, unreadMessages: state.unread, }} /> <ChatContainer> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Chat.ChatContainer`} props={{ componentOwnerId, key: parseHexString(state.key), messages: state.chatMessages, readMessage, addMessageReaction, showThread: state.showThread, threadId: state.threadId, openThread, closeThread, setThread, }} /> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Chat.MessageInput`} props={{ componentOwnerId, selectedChat: state.selectedDM ? state.selectedDM : "#" + state.channelList[state.selectedChannel].name, sendMessage: sendMessage, inputId: state.inputId, resetImage: resetImage, threadReply: state.showThread, }} /> </ChatContainer> </div> </> ) : ( <> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Popups.JoinCurbPopup`} props={{ componentOwnerId, joinCurb, }} /> </> )} </> )} </> ) : ( <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Popups.LoginPopup`} props={{ componentOwnerId }} /> )} </PageContainer> );