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 PageContainer = styled.div` width: 100%; height: calc(100vh - 88px); @media (max-width: 1024px) { height: calc(100vh - 64px); } background-color: #0e0e10; `; const [ loggedIn, setLoggedIn ] = useState( false ); State.init( { 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, openMobileSideMenu: false, } ); const updateMemberList = () => Near.asyncCalimeroView( contract, "get_members" ).then( ( m ) => { State.update( { usersList: m } ); return m; } ); const updateChannelMemberList = useCallback( ( channelId ) => { return Near.asyncCalimeroView( contract, "get_members", { group: { name: channelId } }, undefined, true ).then( ( channelUserList ) => State.update( { channelUserList } ) ); }, [ contract ] ); 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 openSideMenuState = useCallback( () => { State.update( { openMobileSideMenu: true } ); }, [] ); const closeSideMenuState = useCallback( () => { State.update( { openMobileSideMenu: false } ); }, [] ); const onChannelSelected = useCallback( ( channelId ) => { State.update( { selectedChannel: channelId, threadId: -1, showThread: false, settingsId: -1, selectedDM: undefined, openMobileSideMenu: false, } ); updateChannelMemberList( channelId ); }, [ updateChannelMemberList ] ); const onDMSelected = useCallback( ( id ) => { State.update( { selectedDM: id, selectedChannel: undefined, threadId: -1, showThread: false, settingsId: -1, selectedDM: id, openMobileSideMenu: false, } ); }, [] ); 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 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 ping = () => Near.fakCalimeroCall( contract, "ping" ); const fetchKey = useCallback( ( { selectedDM, selectedChannel } ) => { return new Promise( ( resolve, reject ) => { if ( selectedDM && selectedChannel ) { return reject( "Error: You can't fetch a key for both a channel and a user" ); } if ( !selectedDM && !selectedChannel ) { return reject( "Error: You need to provide a channel or a user to fetch the key" ); } if ( !loggedIn ) { return reject( "Error: You need to be logged in to fetch a key" ); } const storedKey = Storage.privateGet( `${ contract }:${ selectedDM || selectedChannel }` ); if ( storedKey ) { console.log( "Using storedKey", storedKey ); return resolve( storedKey ); } console.log( "Fetching a new key" ); 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 ) => { const key = keyData.body.key; Storage.privateSet( `${ contract }:${ selectedDM || selectedChannel }`, key ); resolve( key ); } ) .catch( ( e ) => { console.log( "Error: Calimero.Curb.Chat.UserMessage.fetchKey error, " + e.toString() ); reject( e ); } ); } ); }, [ 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, }, }, 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) { // 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 ] ); useEffect( () => { updateMemberList(); updateChannelList(); }, [ state.selectedChannel, state.selectedDM ] ); useCache( ping, "ping", { subscribe: true } ); useCache( updateUnread, "unread", { subscribe: true } ); const ChatContainer = styled.div` flex: 1; `; const ContentDivContainer = styled.div` display: flex; height: calc(100vh - 169px); width: 100%; @media (max-width: 1024px) { height: calc(100vh - 104px); } `; const BackIcon = styled.i` color: #5765f2; display: none; @media (max-width: 1024px) { display: flex; } maring-right: 14px; `; const Logo = () => ( <Widget src={ `${ componentOwnerId }/widget/Calimero.Curb.Navbar.CurbLogo` } props={ { justify: true, } } /> ); const loginApi = { join: () => Near.fakCalimeroCall( contract, "join" ), login: () => Near.requestCalimeroFak( contract ), validateKey: () => Near.hasValidCalimeroFak( contract ), getMembers: () => Near.asyncCalimeroView( contract, "get_members" ) }; return ( <PageContainer> { !loggedIn ? ( <Widget src={ `${ componentOwnerId }/widget/Calimero.Common.Login.index` } props={ { loginApi, accountId: context.accountId, componentOwnerId, onValidLogin: () => setLoggedIn( true ), logo: <Logo />, } } /> ) : ( <> { 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.selectedDM ? state.selectedDM : state.selectedChannel, 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, mobileSideMenuOpen: state.openMobileSideMenu, backIcon: ( <BackIcon className="bi bi-chevron-left" onClick={ openSideMenuState } /> ), } } /> <ContentDivContainer> <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, mobileOpen: state.openMobileSideMenu, } } /> { !state.openMobileSideMenu && ( <ChatContainer> <Widget src={ `${ componentOwnerId }/widget/Calimero.Curb.Chat.ChatContainer` } props={ { componentOwnerId, fetchKey, fetchMessages, addMessageReaction, selectedDM: state.selectedDM, selectedChannel: state.selectedChannel, sendMessage, contract, } } /> </ChatContainer> ) } </ContentDivContainer> </> ) } </PageContainer> );