const componentOwnerId = props.componentOwnerId; const contract = props.contract; const encryptionUrl = props.encryptionUrl; const accountId = props.accountId; /** * Types of chats. * @typedef {object} ChatTypes * @property {string} CHANNEL - Designates a multi-user chat. * @property {string} DIRECT_MESSAGE - Designates a one-to-one chat. */ const ChatTypes = { CHANNEL: "channel", DIRECT_MESSAGE: "direct_message", }; /** * `curbApi` is a utility object containing several methods for interacting * with a chat API and managing encryption in a chat application. * * @type {Object} * @property {function} createGroup - Sends an API request to create a new chat group. * @property {function} inviteUser - Invites a user to a channel. * @property {function} getChannels - Retrieves all channels that a user is part of. * @property {function} getDMs - Retrieves all direct messages for the user. * @property {function} getUnreadMessages - Retrieves all unread messages for the user. * @property {function} getChannelMeta - Fetches metadata about a particular channel. * @property {function} leaveChannel - Leaves a specified channel. * @property {function} createChannel - Creates a new channel. * @property {function} toggleReaction - Toggles a reaction to a message. * @property {function} fetchMessages - Fetches messages from a specified chat. * @property {function} fetchKey - Obtains the encryption key for a particular chat. * @property {function} sendMessage - Sends a message to a user or channel. * * @property {function} createGroup * @param {string} name - The name of the new group. * @returns {Promise} - A Promise that resolves with the result of the API call. * * @property {function} inviteUser * @param {Object} param * @param {string} param.account - The account of the user to invite. * @param {string} param.channel - The name of the channel to which the user is being invited. * @returns {Promise} - A Promise that resolves with the result of the API call. * * @property {function} getChannels * @returns {Promise<Array>} - A Promise that resolves with an array of channels. * * @property {function} fetchKey * @param {Object} param * @param {Object} param.chat - An object containing the chat details. * @returns {Promise<string>} - A Promise that resolves with the fetched key. * * @property {function} sendMessage * @param {Object} param * @param {string} param.message - The message to be sent. * @param {string} [param.img] - The image to be sent (if any). * @param {string} [param.toAccount] - The account to which the message is to be sent. * @param {string} [param.toChannel] - The channel to which the message is to be sent. * @param {string} param.key - The encryption key for the message. * @param {string} [param.threadId] - The ID of the thread to which the message belongs. * @throws {string} - Throws an error message if the required parameters are not valid. * @returns {Promise} - A Promise that resolves with the result of the API call. */ const curbApi = useMemo(() => { const fetchKey = ({ chat }) => { return new Promise((resolve, reject) => { if (!chat || !chat.type) { return reject( "Error: Invalid chat object, you need to provide a valid chat object to fetch the key" ); } const storageKey = `${contract}:${ chat.type === ChatTypes.CHANNEL ? chat.name : chat.id }`; const storedEncriptionKey = Storage.privateGet( `${contract}:${storageKey}` ); if (storedEncriptionKey) { return resolve(storedEncriptionKey); } const nonce = toHexString(Crypto.randomBytes(16)); Calimero.sign(contract, Buffer.from(accountId + "|" + nonce)) .then((signature) => { const keyBody = { from: accountId, }; if (chat.type === ChatTypes.DIRECT_MESSAGE) { keyBody.to = chat.id; } else { keyBody.group = { name: chat.name }; } 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), }).catch((e) => { return Promise.reject(e); }) }) .then((keyData) => { const key = keyData.body.key; Storage.privateSet(`${contract}:${storageKey}`, key); resolve(key); }) .catch((e) => { console.log( "Error: Calimero.Curb.Chat.UserMessage.fetchKey error, " + e.toString() ); reject(e); }); }); }; const sendMessage = ({ message, img, chat, file, threadId, }) => { if (!message && !img && !file) { // Nothing to send return; } if (!chat) { throw "You need to provide a chat object to send the message"; } return fetchKey({ chat }) .then((key) => { const params = {}; if (chat.type === ChatTypes.DIRECT_MESSAGE) { params.account = chat.id; } else { params.group = { name: chat.name }; } let messageToEncrypt = img ? `${message}$?$https://ipfs.near.social/ipfs/${img.cid}` : message; if (file && file.file.cid) { messageToEncrypt = `${messageToEncrypt}@?$@https://ipfs.near.social/ipfs/${file.file.cid}?fileName=${file.file.name}`; } const encrypted = encrypt(messageToEncrypt, key); params.message = encrypted.text; params.nonce = encrypted.nonce; params.timestamp = Date.now(); params.parent_message = threadId ?? undefined; try { return Near.fakCalimeroCall(contract, "send_message", params); } catch (e) { return Promise.reject(e); } }) }; const parseHexString = (hexString) => { const result = []; while (hexString.length >= 2) { result.push(parseInt(hexString.substring(0, 2), 16)); hexString = hexString.substring(2, hexString.length); } return result; }; const toHexString = (byteArray) => { let result = ""; for (let byte of byteArray) { result += ("0" + (byte & 0xff).toString(16)).slice(-2); } return result; }; const 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) }; }; function decryptMessages(messages, key) { if (!key || key.length === 0) { return []; } const decryptedMessages = messages.map((msg) => { return { ...msg, text: decrypt(msg.text, key, msg.nonce), thread: msg.thread.map((threadMsg) => { return { ...threadMsg, text: decrypt(threadMsg.text, key, threadMsg.nonce), }; }), }; }); return decryptedMessages; } function decrypt(text, key, nonce) { if (!key) { return null; } const byteNonce = []; let hexString = nonce; while (hexString.length >= 2) { byteNonce.push(parseInt(hexString.substring(0, 2), 16)); hexString = hexString.substring(2, hexString.length); } try { const decipher = Crypto.createDecipheriv( "aes-256-cbc", parseHexString(key), byteNonce ); let decrypted = decipher.update(text, "base64", "utf8"); decrypted += decipher.final("utf8"); return decrypted; } catch (e) { console.log("Calimero.decrypt error: ", e.toString()); return null; } } return { createGroup: (name) => Near.fakCalimeroCall(contract, "create_group", { group: { name } }), inviteUser: ({ account, channel }) => Near.fakCalimeroCall(contract, "group_invite", { group: { name: channel }, account, }), getChannels: () => Near.asyncCalimeroView(contract, "get_groups", { account }).then( (groups) => groups.map((group) => ({ ...group, type: ChatTypes.CHANNEL })) ), getDMs: () => Near.asyncCalimeroView(contract, "get_members").then((members) => members.map((member) => ({ ...member, type: ChatTypes.DIRECT_MESSAGE, })) ), getAppName: () => Near.asyncCalimeroView(contract, "get_name"), getChannelMembers: (channelName) => Near.asyncCalimeroView(contract, "get_members", { group: { name: channelName }, }), getUnreadMessages: () => Near.asyncCalimeroView(contract, "unread_messages", { account: accountId, }), getChannelMeta: (channelName) => Near.asyncCalimeroView(contract, "channel_info", { group: { name: channelName }, }), leaveChannel: (channelName) => Near.fakCalimeroCall(contract, "leave_group", { group: { name: channelName }, account: accountId, }), createChannel: (channelName) => Near.fakCalimeroCall(contract, "create_group", { group: { channelName }, }), toggleReaction: ({ messageId, emoji }) => Near.fakCalimeroCall(contract, "toggle_reaction", { message_id: messageId, reaction: emoji, }), fetchMessages: ({ chat }) => new Promise((resolve, reject) => { if (!chat || !chat.type) { reject(`Error: Invalid chat object ${JSON.stringify(chat)}`); return; } let args = {}; if (chat.type === ChatTypes.CHANNEL && chat.name) { args.group = { name: chat.name }; } else if (chat.type === ChatTypes.DIRECT_MESSAGE && chat.id) { args.accounts = [accountId, chat.id]; } else { reject("Error: Invalid chat object"); return; } Promise.all([ fetchKey({ chat }), Near.asyncCalimeroView(contract, "get_messages", args) ]) .then(([key, messages]) => { resolve(decryptMessages(messages, key)); }) .catch((e) => { console.log("Error: Calimero.Curb.Chat.fetchMessages error", e); reject(e); }); }), sendMessage, readMessage: ({ chat, messageId }) => { if (!chat || !chat.type) { return; } const readMessageParams = chat.type === ChatTypes.CHANNEL ? { group: { name: chat.name }, } : { account: chat.id, }; return Near.fakCalimeroCall(contract, "read_message", { message_id: messageId, ...readMessageParams, })}, }; }, [contract, accountId]); /** * Initial version of the chat object, currently only supports channels and p2p DMs. * @typedef {object} Chat * @property {string} type - Can be 'channel' or 'direct_message'. * @property {string} [name] - Name of the channel. * @property {string} [account] - Account for direct message. */ const [activeChat, setActiveChat] = useState({ type: ChatTypes.CHANNEL, name: "general", }); const [isSidebarOpen, setIsSidebarOpen] = useState(false); 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); } `; return ( <> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.NavbarContainer`} props={{ componentOwnerId, activeChat, isSidebarOpen, setIsSidebarOpen, curbApi, }} /> <ContentDivContainer> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.ChannelsContainer`} props={{ componentOwnerId, curbApi, onChatSelected: setActiveChat, activeChat, isSidebarOpen, }} /> {!isSidebarOpen && ( <ChatContainer> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Chat.ChatContainer`} props={{ componentOwnerId, curbApi, activeChat, }} /> </ChatContainer> )} </ContentDivContainer> </> );