const componentOwnerId = props.componentOwnerId; const contract = props.contract; const curbApi = props.curbApi; const activeChat = props.activeChat; const accountId = props.accountId; const wsApi = props.wsApi; const isWsConnectionActive = props.isWsConnectionActive; const ChatContainer = styled.div` background-color: #0e0e10; display: flex; @media (min-width: 1025px) { padding-left: 4px; height: calc(100vh - 169px); } @media (max-width: 1024px) { display: flex; flex-direction: column; padding-top: 104px; } `; const ThreadWrapper = styled.div` height: 100%; flex: 1; border-left: 2px solid #282933; padding-left: 20px; @media (max-width: 1024px) { border-left: none; position: absolute; left: 0px; right: 0px; top: 0px; z-index: 20; background-color: black; } `; const [openThread, setOpenThread] = useState(undefined); const [incomingMessages, setIncomingMessages] = useState([]); const [updatedMessages, setUpdatedMessages] = useState([]); const [incomingThreadMessages, setIncomingThreadMessages] = useState([]); const [updatedThreadMessages, setUpdatedThreadMessages] = useState([]); const activeChatRef = useRef(activeChat); const openThreadRef = useRef(openThread); useEffect(() => { if (activeChat.type === "channel" && isWsConnectionActive) { // todo! move this to page initialization // todo! basically, once the page has loaded, // todo! and we've fetched channels, subscribe to them wsApi.methods.subscribe({ [contract]: [activeChat.name] }, (err, result) => { console.log("subscribed to", activeChat, err, result); }); } activeChatRef.current = activeChat; setOpenThread(undefined); }, [activeChat, isWsConnectionActive]); useEffect(() => { openThreadRef.current = openThread; }, [openThread]); const computeReaction = useCallback((message, reaction, sender) => { const accounts = message.reactions[reaction] ?? []; let update; if (accounts.includes(sender)) { update = accounts.filter((a) => a !== sender); } else { update = [...accounts, sender]; } return { reactions: { ...message.reactions, [reaction]: update } }; }, []); useEffect(() => { const messageListener = (event) => { if ( (activeChatRef.current.type === "direct_message" && event.sender === activeChatRef.current.id) || (activeChatRef.current.type === "channel" && event.receiver.type === "channel" && event.receiver.name === activeChatRef.current.name) ) { curbApi .fetchKey({ chat: activeChatRef.current }) .then((key) => { const decrypted = curbApi.decryptMessages([event], key); if (event.parent_message === openThreadRef.current.id) { setOpenThread({ ...openThreadRef.current, threadCount: openThreadRef.current.threadCount + 1, }); setIncomingThreadMessages(decrypted); } else { setIncomingMessages(decrypted); } }) .catch((err) => { console.log("Failing to decrypt message", err); }); } }; const reactionListener = (event) => { const { message_id, reaction, sender } = event; const updateFunction = (message) => computeReaction(message, reaction, sender); setUpdatedMessages([{ id: message_id, descriptor: { updateFunction } }]); }; wsApi?.notifications?.on("ChannelMessage", messageListener); wsApi?.notifications?.on("MessageReaction", reactionListener); return () => { wsApi?.notifications?.off("ChannelMessage", messageListener); wsApi?.notifications?.off("MessageReaction", reactionListener); }; }, [wsApi, curbApi]); const readMessage = useCallback( (message) => { if (message?.id && message?.sender !== accountId) { // we don't report our own messages curbApi.readMessage({ chat: activeChatRef.current, messageId: message.id, }); } }, [curbApi, accountId] ); const handleReaction = useCallback( (message, reaction) => { const updates = [ { id: message.id, descriptor: { updateFunction: (message) => computeReaction(message, reaction, accountId), }, }, ]; setUpdatedMessages(updates); setUpdatedThreadMessages(updates); curbApi.toggleReaction({ messageId: message.id, reaction }); }, [curbApi] ); const convertCidToUrl = (file) => file ? { ...file, ipfs_cid: `https://ipfs.near.social/ipfs/${file.cid}` } : null; const createTemporalMessage = (text, img, uploadedFile, parentMessage) => { const images = img ? [convertCidToUrl(img)] : []; const files = uploadedFile?.file ? [convertCidToUrl(uploadedFile.file)] : []; const temporalId = curbApi.utils.toHexString(Crypto.randomBytes(16)); return { id: temporalId, nonce: curbApi.utils.toHexString(Crypto.randomBytes(16)), reactions: [], images, files, sender: accountId, text, timestamp: Date.now(), temporalId, parent_message: parentMessage?.id, }; }; const submitMessage = (chat, message) => new Promise((resolve, reject) => { curbApi.sendMessage( { message: message.text, chat, images: message.images, files: message.files, threadId: message.parent_message ? message.parent_message : undefined, }, (err, result) => { if (result) { resolve(result.result.id); } else { reject(err); } } ); }); const createSendMessageHandler = (chat, parentMessage) => (text, img, uploadedFile) => { const temporalMessage = createTemporalMessage( text, img, uploadedFile, parentMessage ); const incomingCall = parentMessage ? setIncomingThreadMessages : setIncomingMessages; incomingCall([temporalMessage]); submitMessage(chat, temporalMessage) .then((realMessageId) => { const update = [ { id: temporalMessage.id, descriptor: { updatedFields: { id: realMessageId, temporalId: undefined }, }, }, ]; if (parentMessage) { const parentUpdate = [ { id: parentMessage.id, descriptor: { updateFunction: (message) => ({ threadCount: message.threadCount + 1, }), }, }, ]; setUpdatedThreadMessages(update); setUpdatedMessages(parentUpdate); } else { setUpdatedMessages(update); } }) .catch((err) => { console.log(err); }); }; const sendMessage = useMemo( () => createSendMessageHandler(activeChat), [activeChat] ); const sendThreadMessage = useMemo( () => createSendMessageHandler(activeChat, openThread), [activeChat, openThread] ); const getIconFromCache = useCallback((accountId) => { const contractId = context.networkId === "mainnet" ? "social.near" : "v1.social08.testnet"; const args = { keys: [`${accountId}/profile/**`], options: {}, }; const contractMethod = "get"; const fallbackImage = "https://i.imgur.com/e8buxpa.png"; return new Promise((resolve, reject) => { Near.asyncView(contractId, contractMethod, args) .then((result) => { let url = ""; if (!Object.keys(result).length === 0) { url = fallbackImage; } const baseImageObject = result[accountId].profile.image; if (baseImageObject.url) { url = baseImageObject.url; } else if (baseImageObject.ipfs_cid) { url = `https://ipfs.near.social/ipfs/${baseImageObject.ipfs_cid}`; } else if (baseImageObject.nft) { const contractId = baseImageObject.nft.contractId; const tokenId = baseImageObject.nft.tokenId; const base_uri = Near.view(contractId, "nft_metadata").base_uri; const mediaRef = Near.view(contractId, "nft_token", { token_id: tokenId, }).metadata.media; url = `${base_uri}/${mediaRef}`; } resolve(url); }) .catch((error) => { reject(error); }); }); }, []); const selectThread = useCallback((message) => { setOpenThread(message); // reset thread updates setIncomingThreadMessages([]); setUpdatedThreadMessages([]); }, []); return ( <ChatContainer> <> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Chat.ChatDisplaySplit`} props={{ componentOwnerId, readMessage, handleReaction, openThread, setOpenThread: selectThread, activeChat, accountId, curbApi, incomingMessages, updatedMessages, resetImage: props.resetImage, sendMessage, getIconFromCache, isThread: false, isReadOnly: activeChat.readOnly, }} /> </> {openThread.id && ( <ThreadWrapper> <Widget src={`${componentOwnerId}/widget/Calimero.Curb.Chat.ChatDisplaySplit`} props={{ componentOwnerId, readMessage, handleReaction, openThread, setOpenThread: selectThread, activeChat, accountId, curbApi, incomingMessages: incomingThreadMessages, updatedMessages: updatedThreadMessages, resetImage: props.resetImage, sendMessage: sendThreadMessage, getIconFromCache, isThread: true, isReadOnly: activeChat.readOnly, }} /> </ThreadWrapper> )} </ChatContainer> );