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 || "calimero.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: -1, 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 = () => State.update({ showThread: true }); const closeThread = () => { State.update({ threadId: -1, showThread: false }); }; const setThread = (messageId) => State.update({ threadId: messageId, showThread: true }); const resetImage = () => State.update({ img: null }); const updateMemberList = () => Near.asyncCalimeroView(contract, "get_members").then((m) => { State.update({ usersList: m }); return m; }); const updateChannelMemberList = (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 })); }; 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 = (id) => { State.update({ selectedChannel: id }); updateChannelMemberList(id); closeThread(); }; const onChangeDMSelected = (id) => { closeThread(); State.update({ selectedDM: id }); }; const onChangeChannelDialog = (open) => { State.update({ openCreateChannel: open }); }; const onChangeChannelSettings = (id) => State.update({ settingsId: id }); const openChannelSettings = (id) => { State.update({ settingsId: id }); }; const onChangeOpenDMs = (open) => State.update({ directMessagesOpen: open }); const channelDetailsClick = (open) => State.update({ channelDetailsOpen: open }); const onAddNewUser = (open) => State.update({ addNewUserClick: open }); const handleClosePopupUser = () => onAddNewUser(false); const onChangeName = ({ target }) => { State.update({ name: target.value }); }; const updateInputId = (id) => { State.update({ inputId: id }); }; const sendMessage = (message) => { if (!message) { return; } let params = {}; if (state.selectedDM) { params = { account: state.selectedDM }; } else { params = { group: state.channelList[state.selectedChannel] }; } params.message = message; if (state.img) { params.message = params.message + `$?$https://ipfs.near.social/ipfs/${state.img.cid}`; resetImage(); } 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)); }; 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 = (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); setMessages(); //const reaction }; const openMemberList = () => { 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 readMessage = (id, params) => id && Near.fakCalimeroCall(contract, "read_message", { message_id: id, ...params }); if (state.bootstraping) { verifyKey(); } const ping = () => Near.fakCalimeroCall(contract, "ping"); if (state.loggedIn) { const keyBody = { from: context.accountId, }; if (state.channelList[state.selectedChannel]) { keyBody.group = state.channelList[state.selectedChannel]; } else { keyBody.to = state.selectedDM; } const cacheKey = { ...keyBody }; keyBody.nonce = toHexString(Crypto.randomBytes(16)); Calimero.sign( contract, Buffer.from(context.accountId + "|" + keyBody.nonce) ).then((signature) => { keyBody.signature = toHexString(signature.signature); const keyData = useCache( () => asyncFetch(encryptionUrl, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(keyBody), }), cacheKey ); 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 = async () => { try { let messages = []; const selectedChannel = state.selectedDM ? state.selectedDM : state.selectedChannel; Near.asyncCalimeroView( contract, "get_messages", state.channelList[state.selectedChannel] ? { group: state.channelList[state.selectedChannel], } : { accounts: [context.accountId, state.selectedDM], }, undefined, true ).then((m) => { messages = m; const storageMessages = Storage.privateGet( "tempMessages" + contract + selectedChannel ); 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 + selectedChannel, JSON.stringify([...threadsArray, ...filteredStorageArray]) ); } const storageReactions = JSON.parse( Storage.privateGet("storageReactions" + contract + selectedChannel) ); 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 + selectedChannel, filteredStorageReactions ); storageReactions.forEach((reaction) => { messages.forEach((message) => { if (reaction.id === message.id) { message.reactions = reaction.reactions; } }); }); } State.update({ chatMessages: messages }); }); } catch (e) { console.log(e); Storage.privateSet("tempMessages" + contract + selectedChannel, ""); } }; updateMemberList(); updateChannelMemberList(); updateChannelList(); updateUnread(); setMessages(); //useCache(ping, "ping", {subscribe: true}); const readMessageParams = state.channelList[state.selectedChannel] ? { group: state.channelList[state.selectedChannel], } : { account: state.selectedDM, }; 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: () => channelDetailsClick(!state.channelDetailsOpen), channelDetailsOpen: state.channelDetailsOpen, channelUserList: state.selectedDM ? [] : state.channelUserList, isDMSelected: !!state.selectedDM, onAddNewUser: () => onAddNewUser(!addNewUserClick), openMemberList: () => openMemberList(), }} /> <div className="d-flex"> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.SideSelector.SideSelector`} props={{ componentOwnerId, onChangeChannelDialog: (open) => onChangeChannelDialog(open), onChannelSelected: (id) => onChannelSelected(id), onChangeChannelSettings: (id) => openChannelSettings(id), onChangeDMSelected: (id) => onChangeDMSelected(id), channelList: state.channelList, selectedChannel: state.selectedChannel, onChangeOpenDMs: (open) => onChangeOpenDMs(open), 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: (id) => readMessage(id, readMessageParams), addMessageReaction: (params) => addMessageReaction(params), showThread: state.showThread, threadId: state.threadId, openThread: () => openThread(), closeThread: () => closeThread(), setThread: (messageId) => setThread(messageId), }} /> <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, img: state.img, uploadComponent: ( <IpfsImageUpload image={state.img} className="btn btn-secondary" /> ), 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> );