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: [], aboutSelected: true, channelMeta: null, functionLoader: false, }); const verifyKey = () => { return Near.hasValidCalimeroFak(contract) .then(result => { if (!result) { State.update({ bootstraping: false, loggedIn: false }); return Promise.reject('Invalid CalimeroFak'); } return Promise.all([ Near.asyncCalimeroView(contract, "get_name", {}, "optimistic"), updateMemberList() ]); }) .then(([organizationName, memberList]) => { if (!memberList.includes(context.accountId)) { return Near.fakCalimeroCall(contract, "join").then(() => { return { organizationName, memberList }; }); } else { return ping().then(() => { return { organizationName, memberList }; }); } }) .then(({ organizationName, memberList }) => { State.update({ bootstraping: false, loggedIn: true, organizationName, members: memberList, }); }) .catch(error => { console.log(error); }); }; useEffect(() => { if (context.accountId && !state.loggedIn && state.bootstraping) { verifyKey(); } }, [context.accountI, state.loggedIn, state.bootstraping]); const updateMemberList = () => Near.asyncCalimeroView(contract, "get_members", {}, "optimistic").then((m) => { State.update({ usersList: m }); return m; }); const updateChannelMemberList = useCallback( (channelId) => { return Near.asyncCalimeroView( contract, "get_members", { group: { name: channelId} }, "optimistic" ).then((channelUserList) => State.update({ channelUserList })); }, [contract] ); const updateUnread = () => Near.asyncCalimeroView( contract, "unread_messages", { account: context.accountId }, "optimistic" ).then((u) => State.update({ unread: u })); const updateChannelList = () => Near.asyncCalimeroView( contract, "get_groups", { account: context.accountId }, "optimistic" ).then((c) => State.update({ channelList: c })); if (state.selectedChannel >= 0) { Near.asyncCalimeroView( contract, "channel_info", { group: state.channelList[state.selectedChannel] }, "optimistic" ).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( (channelId) => { State.update({ selectedChannel: channelId, threadId: -1, showThread: false, settingsId: -1, selectedDM: undefined, }); updateChannelMemberList(channelId); }, [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 sendMessage = useCallback( ( { message, img, toAccount, toChannel, key, threadId } ) => { if (!message && !img) { // Nothing to send return; } if(toAccount && toChannel) { throw "You can't send a message to both a channel and a user"; } if(!toAccount && !toChannel) { throw "You need to provide a channel or a user to send the message"; } if(!key) { throw "You need to provide a key to encrypt the message"; } const params = {}; if (toAccount) { params.account = toAccount; } else { params.group = { name: toChannel }; } const messageToEncrypt = (img) ? message + `$?$https://ipfs.near.social/ipfs/${img.cid}` : message; const encrypted = encrypt(messageToEncrypt, key); params.message = encrypted.text; params.nonce = encrypted.nonce; params.timestamp = Date.now(); params.parent_message = threadId ?? undefined; // const newMessage = { // sender: context.accountId, // thread: [], // ...params, // }; // const selectedChannel = toAccount // ? toAccount // : toChannel; // 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 // ); try { Near.fakCalimeroCall(contract, "send_message", params); } catch(e) { console.log("Error", params, e); } } , [context.accountId, contract]); 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 = (name) => { State.update({ functionLoader: true }); Near.fakCalimeroCall(contract, "create_group", { group: { name }, }).then(() => { State.update({ functionLoader: false }); handleClosePopup(); updateChannelList(); }); }; const handleInviteUser = (account, channel) => { State.update({ functionLoader: true }); Near.fakCalimeroCall(contract, "group_invite", { group: { name: channel }, account, }).then(() => { onAddNewUser(false); State.update({ functionLoader: false }); updateChannelMemberList(channel); }); }; const handleCloseSettingsPopup = () => { swithTab(true); State.update({ channelDetailsOpen: false }); }; const handleClosePopup = useCallback(() => onChangeChannelDialog(false), [onChangeChannelDialog]); 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 }, [] ); const openMemberList = useCallback(() => { swithTab(false); State.update({ channelDetailsOpen: true }); }, []); const isMember = (accountId, members) => { return (members || state.usersList) .map((user) => user.id) .includes(accountId); }; 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] ); const ping = () => { if(state.loggedIn){ Near.fakCalimeroCall(contract, "ping"); } }; const fetchKey = useCallback(({ selectedDM, selectedChannel, }) => new Promise((resolve, reject) => { if(selectedDM && selectedChannel) { reject("Error: You can't fetch a key for both a channel and a user"); return; } if (!selectedDM && !selectedChannel) { reject("Error: You need to provide a channel or a user to fetch the key"); return; } 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 (selectedDM) { keyBody.to = selectedDM; } else { keyBody.group = { name: selectedChannel}; } keyBody.nonce = nonce; keyBody.signature = toHexString(signature.signature); return asyncFetch(encryptionUrl, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(keyBody), }).then((keyData) => resolve(keyData.body.key)) .catch((e) => { reject("Error: Calimero.Curb.Chat.UserMessage.fetchKey error, "+ e.toString()); }) }).then((key) => { resolve(parseHexString(key)); }).catch((e) => { reject("Error: Calimero.Curb.Chat.UserMessage.fetchKey error, ", e ); }); } else { reject("Error: You need to be logged in to fetch a key"); } }), [ state.loggedIn, context.accountId, ]); function arraysAreEqual(arr1, arr2) { if (arr1.length !== arr2.length) { return false; } return arr1.every((value, index) => value === arr2[index]); } const fetchMessages = useCallback(({ selectedDM, selectedChannel, }) => new Promise((resolve, reject) => { try { let messages = []; if(selectedDM && selectedChannel) { reject("Error: You can't fetch a key for both a channel and a user"); return; } if (!selectedDM && !selectedChannel) { reject("Error: You need to provide a channel or a user to fetch the key"); return; } const selectedConversation = selectedDM ? selectedDM : selectedChannel; Near.asyncCalimeroView( contract, "get_messages", selectedDM ? { accounts: [context.accountId, selectedConversation], } : { group: { name: selectedConversation, }, }, "optimistic" ).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) { // console.log("matching", storageItem, 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 }); resolve(messages); }); } catch (e) { console.log(e); // Storage.privateSet("tempMessages" + contract + selectedChannel, ""); reject(); } }), [contract, context.accountId]); useCache(ping, "ping", { subscribe: true }); useCache(updateUnread, "unread", { subscribe: true }); useCache(updateMemberList, "members", { subscribe: true }); useCache(updateChannelList, "channels", { subscribe: true }); const Logo = () => ( <Widget src={`${props.componentOwnerId}/widget/Calimero.Curb.Navbar.CurbLogo`} props={{ justify: true, }} />); return ( <PageContainer> {context.accountId ? ( <> {state.bootstraping ? ( <Widget src={`${componentOwnerId}/widget/Calimero.Common.Popups.Loading`} props={{ logo: <Logo /> }} /> ) : ( <> {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, 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, handleClickEvent: (account) => handleInviteUser(account, state.selectedChannel), 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.selectedChannel, 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, openCreateChannel, onDMSelected, channelList: state.channelList, selectedChannel: state.selectedChannel, onToggleDMs, directMessagesOpen: state.directMessagesOpen, usersList: state.usersList, selectedDM: state.selectedDM, unreadMessages: state.unread, }} /> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Chat.ChatContainer`} props={{ componentOwnerId, fetchKey, fetchMessages, readMessage, addMessageReaction, selectedDM: state.selectedDM, selectedChannel: state.selectedChannel, sendMessage, }} /> </div> </> ) : ( <> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Popups.JoinCurbPopup`} props={{ componentOwnerId, joinCurb, }} /> </> )} </> )} </> ) : ( <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Popups.LoginPopup`} props={{ componentOwnerId }} /> )} </PageContainer> );