diff --git a/app/.prettierignore b/app/.prettierignore new file mode 100644 index 0000000..b85afe7 --- /dev/null +++ b/app/.prettierignore @@ -0,0 +1,24 @@ +# dependencies +node_modules + +# testing +/coverage + +# production +build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/app/.prettierrc.json b/app/.prettierrc.json new file mode 100644 index 0000000..1b2511f --- /dev/null +++ b/app/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": false, + "arrowParens": "always", + "trailingComma": "none" +} \ No newline at end of file diff --git a/app/package.json b/app/package.json index 050372e..24b5eb6 100644 --- a/app/package.json +++ b/app/package.json @@ -36,13 +36,18 @@ }, "scripts": { "start": "react-scripts start", - "build": "react-scripts build" + "build": "react-scripts build", + "prettier": "prettier -c src", + "prettier:fix": "prettier -w src" }, "eslintConfig": { "extends": [ "react-app", "react-app/jest" - ] + ], + "rules": { + "react-hooks/exhaustive-deps": 0 + } }, "browserslist": { "production": [ @@ -79,6 +84,7 @@ "@types/react-redux": "^7.1.20", "@types/redux": "^3.6.0", "@types/redux-thunk": "^2.1.0", - "@types/styled-components": "^5.1.15" + "@types/styled-components": "^5.1.15", + "prettier": "^2.6.2" } } diff --git a/app/src/App.scss b/app/src/App.scss index 027c894..d0b308c 100644 --- a/app/src/App.scss +++ b/app/src/App.scss @@ -38,10 +38,10 @@ } } -.modal-body { +.modal-body { padding: 40px; } -.modal-header { - color: #475C7A; +.modal-header { + color: #475c7a; } diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index 2a68616..744673e 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -1,9 +1,9 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import App from './App'; +import React from "react" +import { render, screen } from "@testing-library/react" +import App from "./App" -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); +test("renders learn react link", () => { + render() + const linkElement = screen.getByText(/learn react/i) + expect(linkElement).toBeInTheDocument() +}) diff --git a/app/src/App.tsx b/app/src/App.tsx index bec9572..b1e6583 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,6 +1,6 @@ -import "./App.scss"; -import { BrowserRouter } from "react-router-dom"; -import AppWrapper from "./components/AppWrapper"; +import "./App.scss" +import { BrowserRouter } from "react-router-dom" +import AppWrapper from "./components/AppWrapper" function App() { return ( @@ -11,7 +11,7 @@ function App() { - ); + ) } -export default App; +export default App diff --git a/app/src/components/AppWrapper/index.tsx b/app/src/components/AppWrapper/index.tsx index f519ff4..66141ec 100644 --- a/app/src/components/AppWrapper/index.tsx +++ b/app/src/components/AppWrapper/index.tsx @@ -1,69 +1,104 @@ -import React from "react"; -import { Routes, Route, Navigate } from "react-router-dom"; -import { useLocation, useNavigate } from "react-router"; -import RegisterOrRecover from "../RegisterOrRecover"; -import Dashboard from "../Dashboard"; -import { init, receive_message } from "rln-client-lib"; -import { useEffect } from "react"; -import { useAppDispatch } from "../../redux/hooks/useAppDispatch"; +import React from "react" +import PublicRoomInvitedScreen from "../PublicRoomInvitedScreen" +import RegisterOrRecover from "../RegisterOrRecover" +import Dashboard from "../Dashboard" +import SyncSpinner from "../Spinner" +import { Routes, Route, Navigate } from "react-router-dom" +import { useLocation, useNavigate } from "react-router" +import { get_rooms, init, receive_message } from "rln-client-lib" +import { useEffect } from "react" +import { useAppDispatch } from "../../redux/hooks/useAppDispatch" import { addMessageToRoomAction, - getChatHistoryAction, - getRoomsAction -} from "../../redux/actions/actionCreator"; -import PublicRoomInvitedScreen from "../PublicRoomInvitedScreen"; -import { roomTypes, serverUrl, socketUrl } from "../../constants/constants"; -import { ToastContainer } from 'react-toastify'; + getRoomsAction, + loadMessagesForRooms, + runSyncMessageHistory +} from "../../redux/actions/actionCreator" +import { roomTypes, serverUrl, socketUrl } from "../../constants/constants" +import { ToastContainer } from "react-toastify" import 'react-toastify/dist/ReactToastify.css'; import { generateProof } from "../../util/util"; +import "react-toastify/dist/ReactToastify.css" +import { IMessage } from "rln-client-lib/dist/src/chat/interfaces" +import { IRooms } from "rln-client-lib/dist/src/profile/interfaces" +import { useAppSelector } from "../../redux/hooks/useAppSelector" const AppWrapper = () => { - const navigate = useNavigate(); - const dispatch = useAppDispatch(); - const location = useLocation(); + const navigate = useNavigate() + const dispatch = useAppDispatch() + const location = useLocation() + + const isChatHistorySyncing = useAppSelector( + (state) => state.ChatReducer.chatHistorySyncing + ) useEffect(() => { - initializeApp(); + initializeApp() // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, []) const initializeApp = async () => { try { await init({ serverUrl: serverUrl, socketUrl: socketUrl - }, - generateProof) - .then(() => { - if (!location.pathname.includes(roomTypes.public)) - navigate("/dashboard"); - dispatch(getRoomsAction()); - dispatch(getChatHistoryAction()); - }) - .then(async () => { - await receive_message(receiveMessageCallback); - }); + }, generateProof).then(() => { + if (!location.pathname.includes(roomTypes.public)) + navigate("/dashboard") + dispatch(getRoomsAction()) + dispatch( + runSyncMessageHistory({ + onSuccess: () => { + loadMessagesFromDb() + } + }) + ) + }) } catch (error) { - navigate("/r-procedure"); + navigate("/r-procedure") } - }; + } + + const loadMessagesFromDb = async () => { + const allRooms: IRooms = await get_rooms() + const roomIds: string[] = [ + ...allRooms.direct.map((d) => d.id), + ...allRooms.private.map((d) => d.id), + ...allRooms.public.map((d) => d.id) + ] + + const nowTimestamp: number = new Date().getTime() + dispatch(loadMessagesForRooms(roomIds, nowTimestamp)) + + await receive_message(receiveMessageCallback) + } - const receiveMessageCallback = (message: any, roomId: string) => { - dispatch(addMessageToRoomAction(message, roomId)); - }; + const receiveMessageCallback = (message: IMessage, roomId: string) => { + dispatch(addMessageToRoomAction(message, roomId)) + } return (
- - } /> - } /> - } /> - } /> - - + {isChatHistorySyncing ? ( + + ) : ( + <> + {" "} + + } /> + } /> + } + /> + } /> + + + + )}
- ); -}; + ) +} -export default AppWrapper; +export default AppWrapper diff --git a/app/src/components/ChatMessages/ChatMessagesWrapper.tsx b/app/src/components/ChatMessages/ChatMessagesWrapper.tsx new file mode 100644 index 0000000..2bfad2f --- /dev/null +++ b/app/src/components/ChatMessages/ChatMessagesWrapper.tsx @@ -0,0 +1,116 @@ +import React, { useEffect, useRef } from "react" +import { Spinner } from "reactstrap" +import styled from "styled-components" +import * as Colors from "../../constants/colors" +import { loadMessagesForRoom } from "../../redux/actions/actionCreator" +import { useAppDispatch } from "../../redux/hooks/useAppDispatch" +import { useAppSelector } from "../../redux/hooks/useAppSelector" + +const StyledSingleMessage = styled.div` + font-size: 16px; + color: black; + border-radius: 10px; + padding: 8px 12px; + margin-bottom: 16px; + width: fit-content; + text-align: left; +` +const StyledMessagesWrapper = styled.div` + overflow-y: scroll; + padding: 20px 40px; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + height: 85%; + div:nth-child(odd) { + background: ${Colors.ANATRACITE}; + color: white; + } + div:nth-child(even) { + background: #f0f2f5; + } +` + +const StyledSpinnerWrapper = styled.div` + span { + color: ${Colors.ANATRACITE}; + } + height: 85%; + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; +` + +interface ChatMessagesProps { + chatHistory: any[] + chatRef: React.RefObject + currentActiveRoom: any +} + +const ChatMessagesWrapper = ({ + chatHistory, + chatRef, + currentActiveRoom +}: ChatMessagesProps) => { + const dispatch = useAppDispatch() + const sortedHistory = chatHistory.sort((a, b) => a.epoch - b.epoch) + const chatHistoryRef = useRef(sortedHistory) + const loadingMessages = useAppSelector((state) => state.ChatReducer.loading) + + const handleScroll = () => { + // @ts-ignore + if (chatRef.current.scrollTop === 0) { + fetchNewMessages() + } + } + + const fetchNewMessages = () => { + const lastMessage = chatHistoryRef.current[0] + + dispatch( + loadMessagesForRoom( + currentActiveRoom.id, + new Date(lastMessage.epoch).getTime(), + false, + { + onSuccess: (res: any) => { + chatHistoryRef.current = res.concat(sortedHistory) + } + } + ) + ) + } + + useEffect(() => { + chatRef.current?.addEventListener("scroll", handleScroll) + return () => { + chatRef.current?.removeEventListener("scroll", handleScroll) + } + }, []) + + return loadingMessages ? ( + + <> + {" "} + Loading messages + + + ) : ( + + {sortedHistory.length > 0 && + sortedHistory.map((messageObj) => ( + + {messageObj.message_content} + + ))} + + ) +} + +export default ChatMessagesWrapper diff --git a/app/src/components/ChatMessages/index.tsx b/app/src/components/ChatMessages/index.tsx index 5644f20..8fbf35c 100644 --- a/app/src/components/ChatMessages/index.tsx +++ b/app/src/components/ChatMessages/index.tsx @@ -1,15 +1,18 @@ import React, { useEffect, useRef, useState } from "react"; import styled from "styled-components"; import * as Colors from "../../constants/colors"; -import { faTimes } from "@fortawesome/free-solid-svg-icons"; +import { faTimes, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { clientUrl, roomTypes } from "../../constants/constants"; import { useAppDispatch } from "../../redux/hooks/useAppDispatch"; -import { addActiveChatRoom } from "../../redux/actions/actionCreator"; +import { addActiveChatRoom, deleteMessagesForRoom } from "../../redux/actions/actionCreator"; import InviteModal from "../Modals/inviteModal"; import { useAppSelector } from "../../redux/hooks/useAppSelector"; import Input from "../Input"; import ReactTooltip from "react-tooltip"; +import ChatMessagesWrapper from "./ChatMessagesWrapper" +import { delete_messages_for_room } from "rln-client-lib"; + const StyledChatContainer = styled.div` background: white; @@ -17,32 +20,7 @@ const StyledChatContainer = styled.div` border-radius: 18px; box-shadow: 0px 8px 14px 0px #a0a0a0; padding: 20px 40px; -`; -const StyledSingleMessage = styled.div` - font-size: 16px; - color: black; - border-radius: 10px; - padding: 8px 12px; - margin-bottom: 16px; - width: fit-content; - text-align: left; -`; -const StyledMessagesWrapper = styled.div` - overflow-y: scroll; - padding: 20px 40px; - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } - height: 85%; - div:nth-child(odd) { - background: ${Colors.ANATRACITE}; - color: white; - } - div:nth-child(even) { - background: #f0f2f5; - } -`; +` const StyledChatDetailsWrapper = styled.div` display: flex; @@ -53,12 +31,12 @@ const StyledChatDetailsWrapper = styled.div` fill: ${Colors.ANATRACITE}; } } -`; +` const StyledChatRoomsTitle = styled.p` color: ${Colors.ANATRACITE}; font-weight: 600; font-size: 24px; -`; +` const StyledButton = styled.button` background: ${Colors.BERRY_PINK}; @@ -73,11 +51,19 @@ const StyledButton = styled.button` box-shadow: 0px 0px 15px 0px ${Colors.BERRY_PINK}; } width: 180px; -`; +` + +const StyledIconWrapper = styled.div` + margin: 0 30px; +` +const StyledChatCommandsWrapper = styled.div` + display: flex; + align-items: center; +` type ChatMessagesProps = { - currentActiveRoom: any; -}; + currentActiveRoom: any +} const ChatMessages = ({ currentActiveRoom }: ChatMessagesProps) => { const [toggleInviteModal, setToggleInviteModal] = useState(false); @@ -86,39 +72,48 @@ const ChatMessages = ({ currentActiveRoom }: ChatMessagesProps) => { ); //@ts-ignore const chatHistoryByRoom: any[] = useAppSelector( - state => state.ChatReducer.chatHistory[currentActiveRoom.id] - ); - const chatRef = useRef(null); - const dispatch = useAppDispatch(); + (state) => state.ChatReducer.chatHistory[currentActiveRoom.id] + ) + const stayOnBottom: boolean = useAppSelector( + (state) => state.ChatReducer.stayOnBottom + ) + const chatRef = useRef(null) + const dispatch = useAppDispatch() const scrollToBottom = () => { if (chatRef.current) { //Scroll to bottom - chatRef.current.scrollTop = chatRef.current.scrollHeight; + chatRef.current.scrollTop = chatRef.current.scrollHeight } - }; + } useEffect(() => { - scrollToBottom(); - }, [chatHistoryByRoom]); + if (stayOnBottom) scrollToBottom() + }, [chatHistoryByRoom, stayOnBottom]) const handleActiveChatClosing = () => { - dispatch(addActiveChatRoom(undefined)); - }; + dispatch(addActiveChatRoom(undefined)) + } const handleGenerateInvitePublicRoomLink = () => { - const publicRoomInviteLink = `${clientUrl}/public/${currentActiveRoom.id}`; + const publicRoomInviteLink = `${clientUrl}/public/${currentActiveRoom.id}` navigator.clipboard.writeText(publicRoomInviteLink).then(() => { setIsPublicRoomInviteCopied(true) - setTimeout(() => setIsPublicRoomInviteCopied(false), 4000); - }); - }; + setTimeout(() => setIsPublicRoomInviteCopied(false), 4000) + }) + } + + const handleMessagesDeleting = () => { + delete_messages_for_room(currentActiveRoom.id).then(() => + dispatch(deleteMessagesForRoom(currentActiveRoom.id)) + ) + } return ( {currentActiveRoom.name} -
+ {currentActiveRoom.type.toLowerCase() === roomTypes.private && ( <> setToggleInviteModal(true)}> @@ -150,24 +145,37 @@ const ChatMessages = ({ currentActiveRoom }: ChatMessagesProps) => { )} + + + + Delete Message History + + -
+
{" "} - - {chatHistoryByRoom?.length > 0 && - chatHistoryByRoom.map(messageObj => ( - - {messageObj.message_content} - - ))} - +
- ); -}; + ) +} -export default ChatMessages; +export default ChatMessages diff --git a/app/src/components/ChatRooms/index.tsx b/app/src/components/ChatRooms/index.tsx index 3d29a5a..c172ae2 100644 --- a/app/src/components/ChatRooms/index.tsx +++ b/app/src/components/ChatRooms/index.tsx @@ -1,14 +1,14 @@ -import React, { useEffect } from "react"; -import styled from "styled-components"; -import * as Colors from "../../constants/colors"; +import React, { useEffect } from "react" +import styled from "styled-components" +import * as Colors from "../../constants/colors" import { addActiveChatRoom, getRoomsAction, Room -} from "../../redux/actions/actionCreator"; -import { useAppDispatch } from "../../redux/hooks/useAppDispatch"; -import { useAppSelector } from "../../redux/hooks/useAppSelector"; -import RoomHandlingButtons from "../RoomHandlingButtons"; +} from "../../redux/actions/actionCreator" +import { useAppDispatch } from "../../redux/hooks/useAppDispatch" +import { useAppSelector } from "../../redux/hooks/useAppSelector" +import RoomHandlingButtons from "../RoomHandlingButtons" const StyledChatRoomWrapper = styled.div` background: white; @@ -17,7 +17,7 @@ const StyledChatRoomWrapper = styled.div` border-radius: 18px; padding: 20px 40px; margin-right: 16px; -`; +` const StyledChatRoomCell = styled.div` background: ${Colors.DARK_YELLOW}; border-radius: 8px; @@ -31,13 +31,13 @@ const StyledChatRoomCell = styled.div` transition: 0.15s; box-shadow: 0px 0px 15px 0px ${Colors.DARK_YELLOW}; } -`; +` export const StyledChatRoomsTitle = styled.p` color: ${Colors.DARK_YELLOW}; font-weight: 600; font-size: 24px; text-transform: capitalize; -`; +` const StyledChatRoomsWrapper = styled.div` overflow-y: scroll; padding: 20px 40px; @@ -46,27 +46,27 @@ const StyledChatRoomsWrapper = styled.div` display: none; } height: 85%; -`; +` const ChatRooms = () => { - const rooms: any = useAppSelector(state => state.ChatReducer.rooms); - const dispatch = useAppDispatch(); + const rooms: any = useAppSelector((state) => state.ChatReducer.rooms) + const dispatch = useAppDispatch() useEffect(() => { - dispatch(getRoomsAction()); + dispatch(getRoomsAction()) // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, []) const handleChatRoomCellClick = (room: Room) => { - dispatch(addActiveChatRoom(room)); - }; + dispatch(addActiveChatRoom(room)) + } return ( <> Chat rooms - {Object.keys(rooms).map(key => ( + {Object.keys(rooms).map((key) => ( {`${key}: ${rooms[key].length}`} {rooms[key].map((room: Room) => ( @@ -83,7 +83,7 @@ const ChatRooms = () => { - ); -}; + ) +} -export default ChatRooms; +export default ChatRooms diff --git a/app/src/components/Dashboard/index.tsx b/app/src/components/Dashboard/index.tsx index 183311e..b5255f5 100644 --- a/app/src/components/Dashboard/index.tsx +++ b/app/src/components/Dashboard/index.tsx @@ -1,27 +1,33 @@ -import React from "react"; -import styled from "styled-components"; -import ChatRooms from "../ChatRooms"; -import ChatMessages from "../ChatMessages"; -import { useAppSelector } from "../../redux/hooks/useAppSelector"; +import React from "react" +import styled from "styled-components" +import ChatRooms from "../ChatRooms" +import ChatMessages from "../ChatMessages" +import { useAppSelector } from "../../redux/hooks/useAppSelector" +import SyncSpinner from "../Spinner" const StyledDashboardWrapper = styled.div` height: 100vh; padding: 1rem; -`; +` const Dashboard = () => { const currentActiveRoom: any = useAppSelector( - state => state.ChatReducer.currentActiveRoom - ); + (state) => state.ChatReducer.currentActiveRoom + ) + const isChatHistorySyncing = useAppSelector( + (state) => state.ChatReducer.chatHistorySyncing + ) - return ( + return isChatHistorySyncing ? ( + + ) : ( {currentActiveRoom && ( )} - ); -}; + ) +} -export default Dashboard; +export default Dashboard diff --git a/app/src/components/Input/index.tsx b/app/src/components/Input/index.tsx index 57b9fae..ed49540 100644 --- a/app/src/components/Input/index.tsx +++ b/app/src/components/Input/index.tsx @@ -1,11 +1,11 @@ -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React from "react"; -import styled from "styled-components"; -import * as Colors from "../../constants/colors"; -import { faPaperPlane } from "@fortawesome/free-solid-svg-icons"; -import { Room } from "../../redux/actions/actionCreator"; -import { send_message } from "rln-client-lib"; -import { toast } from 'react-toastify'; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import React from "react" +import styled from "styled-components" +import * as Colors from "../../constants/colors" +import { faPaperPlane } from "@fortawesome/free-solid-svg-icons" +import { Room } from "../../redux/actions/actionCreator" +import { send_message } from "rln-client-lib" +import { toast } from "react-toastify" const StyledInput = styled.input` border: 1px solid #f0f2f5; @@ -17,7 +17,7 @@ const StyledInput = styled.input` &:active { outline: none; } -`; +` const StyledChatFooterWrapper = styled.div` display: flex; @@ -30,60 +30,60 @@ const StyledChatFooterWrapper = styled.div` fill: ${Colors.ANATRACITE}; } } -`; +` type InputProps = { - currentActiveRoom: Room; -}; + currentActiveRoom: Room +} type InputState = { - inputValue: string; -}; + inputValue: string +} class Input extends React.Component { constructor(props: InputProps) { - super(props); + super(props) this.state = { inputValue: "" - }; + } } componentDidMount() { - window.addEventListener("keydown", this.handleKeyDown); + window.addEventListener("keydown", this.handleKeyDown) } componentWillUnmount() { - window.removeEventListener("keydown", this.handleKeyDown); + window.removeEventListener("keydown", this.handleKeyDown) } - handleKeyDown = async(event: KeyboardEvent) => { - if (event.code === "Enter") - await this.handleMessageSubmit(); - }; + handleKeyDown = async (event: KeyboardEvent) => { + if (event.code === "Enter") await this.handleMessageSubmit() + } - handleMessageSubmit = async() => { - const { inputValue } = this.state; - const { currentActiveRoom } = this.props; + handleMessageSubmit = async () => { + const { inputValue } = this.state + const { currentActiveRoom } = this.props if (inputValue) { try { - await send_message(currentActiveRoom.id, inputValue); + await send_message(currentActiveRoom.id, inputValue) this.setState({ inputValue: "" }) } catch (error) { - toast.error("Error while sending the message. You are either banned from the chat or deleted from InteRep"); + console.log(error) + toast.error( + "Error while sending the message. You are either banned from the chat or deleted from InteRep" + ) } } - }; - - + } render() { - const { inputValue } = this.state; + const { inputValue } = this.state return ( this.setState({ inputValue: e.target.value })} + onChange={(e) => this.setState({ inputValue: e.target.value })} />{" "}
{" "} @@ -92,10 +92,9 @@ class Input extends React.Component { onClick={this.handleMessageSubmit} />{" "}
-
- ); + ) } } -export default Input; +export default Input diff --git a/app/src/components/Modals/exchangeKeysModal.tsx b/app/src/components/Modals/exchangeKeysModal.tsx new file mode 100644 index 0000000..5a3a710 --- /dev/null +++ b/app/src/components/Modals/exchangeKeysModal.tsx @@ -0,0 +1,155 @@ +import { useState } from "react" +import { Modal, ModalBody, ModalHeader } from "reactstrap" +import styled from "styled-components" +import * as Colors from "../../constants/colors" +import { faCopy } from "@fortawesome/free-solid-svg-icons" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import ReactTooltip from "react-tooltip" +import { + generate_encrypted_invite_direct_room, + update_direct_room_key +} from "rln-client-lib" +import { useAppSelector } from "../../redux/hooks/useAppSelector" + +const StyledButton = styled.button` + background: ${Colors.ANATRACITE}; + border-radius: 8px; + border: none; + outline: none; + padding: 8px 12px; + margin: 8px; + margin-left: auto; + color: white; + &:hover { + transition: 0.15s; + box-shadow: 0px 0px 15px 0px ${Colors.ANATRACITE}; + } + width: 200px; +` + +const StyledTextarea = styled.textarea` + border: 1px solid #f0f2f5; + border-radius: 20px; + width: 100%; + position: relative; + margin-bottom: 10px; + padding: 8px 12px; + min-height: 40px; + &:focus, + &:active { + outline: none; + } +` +const StyledInviteCodeOuterWrapper = styled.div` + color: ${Colors.ANATRACITE}; + margin: 8px; + display: flex; + align-items: center; + + svg { + font-size: 30px; + cursor: pointer; + position: relative; + left: 10px; + } +` + +type InviteModalProps = { + setToggleExchangeKeysModal: (shouldToggle: boolean) => void + toggleExchangeKeysModal: boolean +} + +const ExcangeKeysModal = ({ + setToggleExchangeKeysModal, + toggleExchangeKeysModal +}: InviteModalProps) => { + const [roomKey, setRoomKey] = useState("") + const [recipientKey, setRecipientKey] = useState("") + const [isInviteCopied, setIsInviteCopied] = useState(false) + const currentActiveRoom = useAppSelector( + (state) => state.ChatReducer.currentActiveRoom + ) + + const handleInviteCopying = () => { + setIsInviteCopied(true) + navigator.clipboard.writeText(roomKey).then(() => { + setTimeout(() => setIsInviteCopied(false), 4000) + }) + } + + const handleGenerateRoomKey = async () => { + try { + //@ts-ignore + await generate_encrypted_invite_direct_room(currentActiveRoom?.id).then( + (invite) => setRoomKey(invite) + ) + } catch (error) { + setRoomKey("Error while generating invite") + } + } + + const handleUpdateKey = async () => { + try { + //@ts-ignore + await update_direct_room_key(currentActiveRoom?.id, recipientKey).then( + (invite) => { + setRoomKey("") + setRecipientKey("") + setToggleExchangeKeysModal(false) + } + ) + } catch (error) { + console.log(error) + } + } + + const handleModalClosing = () => { + setRoomKey("") + setRecipientKey("") + setToggleExchangeKeysModal(false) + } + + return ( + + Exchange keys + + + + {isInviteCopied ? "Key copied" : "Copy the key"} + + + {roomKey && ( + + )} + + + Generate key + + setRecipientKey(e.target.value)} + placeholder="Enter your recipient's key..." + /> + Update key + + + ) +} + +export default ExcangeKeysModal diff --git a/app/src/components/Modals/generatePublicKey.tsx b/app/src/components/Modals/generatePublicKey.tsx index d78da64..5e848c3 100644 --- a/app/src/components/Modals/generatePublicKey.tsx +++ b/app/src/components/Modals/generatePublicKey.tsx @@ -1,11 +1,11 @@ -import { useEffect, useState } from "react"; -import { Modal, ModalBody, ModalHeader } from "reactstrap"; -import styled from "styled-components"; -import * as Colors from "../../constants/colors"; -import { faCopy } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import ReactTooltip from "react-tooltip"; -import { get_public_key } from "rln-client-lib"; +import { useEffect, useState } from "react" +import { Modal, ModalBody, ModalHeader } from "reactstrap" +import styled from "styled-components" +import * as Colors from "../../constants/colors" +import { faCopy } from "@fortawesome/free-solid-svg-icons" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import ReactTooltip from "react-tooltip" +import { get_public_key } from "rln-client-lib" const StyledTextarea = styled.textarea` border: 1px solid #f0f2f5; @@ -19,7 +19,7 @@ const StyledTextarea = styled.textarea` &:active { outline: none; } -`; +` const StyledInviteCodeOuterWrapper = styled.div` color: ${Colors.ANATRACITE}; margin: 8px; @@ -31,47 +31,47 @@ const StyledInviteCodeOuterWrapper = styled.div` position: relative; left: 10px; } -`; +` type InviteModalProps = { - setToggleGeneratePublicKey: (shouldToggle: boolean) => void; - toggleGeneratePublicKey: boolean; -}; + setToggleGeneratePublicKey: (shouldToggle: boolean) => void + toggleGeneratePublicKey: boolean +} const GeneratePublicKeyModal = ({ setToggleGeneratePublicKey, toggleGeneratePublicKey }: InviteModalProps) => { - const [generatedInvite, setDisplayGeneratedInvite] = useState(""); - const [isInviteCopied, setIsInviteCopied] = useState(false); + const [generatedInvite, setDisplayGeneratedInvite] = useState("") + const [isInviteCopied, setIsInviteCopied] = useState(false) useEffect(() => { - if (toggleGeneratePublicKey) generatePublicKey(); - }, [toggleGeneratePublicKey]); + if (toggleGeneratePublicKey) generatePublicKey() + }, [toggleGeneratePublicKey]) const handleInviteCopying = () => { navigator.clipboard.writeText(generatedInvite).then(() => { - setIsInviteCopied(true); - setTimeout(() => setIsInviteCopied(false), 4000); - }); - }; + setIsInviteCopied(true) + setTimeout(() => setIsInviteCopied(false), 4000) + }) + } const generatePublicKey = async () => { try { - get_public_key().then(key => { - return setDisplayGeneratedInvite(key); - }); + get_public_key().then((key) => { + return setDisplayGeneratedInvite(key) + }) } catch (error) { - console.log(error); + console.log(error) } - }; + } return ( { - setDisplayGeneratedInvite(""); - setToggleGeneratePublicKey(false); + setDisplayGeneratedInvite("") + setToggleGeneratePublicKey(false) }} > Generate Public Key @@ -99,7 +99,7 @@ const GeneratePublicKeyModal = ({ - ); -}; + ) +} -export default GeneratePublicKeyModal; +export default GeneratePublicKeyModal diff --git a/app/src/components/Modals/index.tsx b/app/src/components/Modals/index.tsx index e03a7ac..c3f4430 100644 --- a/app/src/components/Modals/index.tsx +++ b/app/src/components/Modals/index.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; -import { Modal, ModalBody, ModalFooter, ModalHeader } from "reactstrap"; -import styled from "styled-components"; -import * as Colors from "../../constants/colors"; -import { roomTypes } from "../../constants/constants"; +import { useState } from "react" +import { Modal, ModalBody, ModalFooter, ModalHeader } from "reactstrap" +import styled from "styled-components" +import * as Colors from "../../constants/colors" +import { roomTypes } from "../../constants/constants" import { createDirectRoomAction, createPublicRoomAction, @@ -12,7 +12,7 @@ import { useAppDispatch } from "../../redux/hooks/useAppDispatch"; import TrustedContactsList from "./trustedContactsList"; const StyledButton = styled.button` - background: ${props => props.color}; + background: ${(props) => props.color}; border-radius: 8px; border: none; outline: none; @@ -21,10 +21,10 @@ const StyledButton = styled.button` color: white; &:hover { transition: 0.15s; - box-shadow: 0px 0px 15px 0px ${props => props.color}; + box-shadow: 0px 0px 15px 0px ${(props) => props.color}; } width: 200px; -`; +` const StyledInput = styled.input` border: 1px solid #f0f2f5; @@ -37,53 +37,53 @@ const StyledInput = styled.input` &:active { outline: none; } -`; +` const StyledButtonsWrapper = styled.div` display: flex; -`; +` type RoomOptionsModalProps = { - setToggleModal: (shouldToggle: boolean) => void; - toggleModal: boolean; -}; + setToggleModal: (shouldToggle: boolean) => void + toggleModal: boolean +} type RoomsOptionsModalProps = { - setToggleModal: (shouldToggle: boolean) => void; - toggleModal: boolean; - setModalContent: (modalContent: string) => void; -}; + setToggleModal: (shouldToggle: boolean) => void + toggleModal: boolean + setModalContent: (modalContent: string) => void +} const RoomOptionsModal = ({ toggleModal, setToggleModal }: RoomOptionsModalProps) => { - const [modalContent, setModalContent] = useState(""); - const dispatch = useAppDispatch(); + const [modalContent, setModalContent] = useState("") + const dispatch = useAppDispatch() const handleRoomCreation = (roomType: string, data: any) => { if (data.name) { switch (roomType) { case roomTypes.public: - dispatch(createPublicRoomAction(data.name)); - break; + dispatch(createPublicRoomAction(data.name)) + break case roomTypes.private: - dispatch(createPrivateRoomAction(data.name)); - break; + dispatch(createPrivateRoomAction(data.name)) + break case roomTypes.oneOnOne: - dispatch(createDirectRoomAction(data.name, data.publicKey)); - break; + dispatch(createDirectRoomAction(data.name, data.publicKey)) + break default: - return handleModalClosing(); + return handleModalClosing() } } - handleModalClosing(); - }; + handleModalClosing() + } const handleModalClosing = () => { - setModalContent(""); - setToggleModal(false); - }; + setModalContent("") + setToggleModal(false) + } const getModalContent = () => { switch (modalContent) { @@ -95,7 +95,7 @@ const RoomOptionsModal = ({ modalContent={modalContent} handleModalClosing={handleModalClosing} /> - ); + ) case roomTypes.private: return ( @@ -105,7 +105,7 @@ const RoomOptionsModal = ({ modalContent={modalContent} handleModalClosing={handleModalClosing} /> - ); + ) case roomTypes.oneOnOne: return ( @@ -115,7 +115,7 @@ const RoomOptionsModal = ({ modalContent={modalContent} handleModalClosing={handleModalClosing} /> - ); + ) default: return ( - ); + ) } - }; - return getModalContent(); -}; + } + return getModalContent() +} const RoomTypeOptions = ({ toggleModal, @@ -163,15 +163,15 @@ const RoomTypeOptions = ({ {" "} - ); -}; + ) +} type RoomNameInputProps = { - toggleModal: boolean; - handleRoomCreation: (roomType: string, data: any) => void; - modalContent: string; - handleModalClosing: () => void; -}; + toggleModal: boolean + handleRoomCreation: (roomType: string, data: any) => void + modalContent: string + handleModalClosing: () => void +} const RoomNameInput = ({ toggleModal, @@ -179,7 +179,7 @@ const RoomNameInput = ({ modalContent, handleModalClosing }: RoomNameInputProps) => { - const [roomName, setRoomName] = useState(""); + const [roomName, setRoomName] = useState("") return ( @@ -188,7 +188,7 @@ const RoomNameInput = ({ setRoomName(e.target.value)} + onChange={(e) => setRoomName(e.target.value)} /> @@ -200,25 +200,25 @@ const RoomNameInput = ({ - ); -}; + ) +} const OneOnOneModal = ({ toggleModal, handleRoomCreation, handleModalClosing }: RoomNameInputProps) => { - const [roomName, setRoomName] = useState(""); - const [publicKey, setPublicKey] = useState(""); + const [roomName, setRoomName] = useState("") + const [publicKey, setPublicKey] = useState("") const handleDirectRoomCreation = () => { if (publicKey && roomName) { handleRoomCreation(roomTypes.oneOnOne, { name: roomName, publicKey - }); + }) } - }; + } return ( @@ -228,7 +228,7 @@ const OneOnOneModal = ({ setRoomName(e.target.value)} + onChange={(e) => setRoomName(e.target.value)} /> @@ -241,6 +241,6 @@ const OneOnOneModal = ({ - ); -}; -export default RoomOptionsModal; + ) +} +export default RoomOptionsModal diff --git a/app/src/components/Modals/inviteModal.tsx b/app/src/components/Modals/inviteModal.tsx index 15f57de..77ac8c5 100644 --- a/app/src/components/Modals/inviteModal.tsx +++ b/app/src/components/Modals/inviteModal.tsx @@ -24,21 +24,21 @@ const StyledButton = styled.button` box-shadow: 0px 0px 15px 0px ${Colors.ANATRACITE}; } width: 200px; -`; +` const StyledInviteCodeOuterWrapper = styled.div` color: ${Colors.ANATRACITE}; margin: 8px; display: flex; align-items: center; - + svg { font-size: 30px; cursor: pointer; position: relative; left: 10px; } -`; +` const StyledInviteCodeInnerWrapper = styled.div` border: 1px solid #f0f2f5; border-radius: 20px; @@ -47,39 +47,40 @@ const StyledInviteCodeInnerWrapper = styled.div` padding: 8px 12px; margin: 10px 0; word-break: break-all; -`; - +` + type InviteModalProps = { - setToggleInviteModal: (shouldToggle: boolean) => void; - toggleInviteModal: boolean; -}; + setToggleInviteModal: (shouldToggle: boolean) => void + toggleInviteModal: boolean +} const InviteModal = ({ setToggleInviteModal, toggleInviteModal }: InviteModalProps) => { - const [keyValue, setKeyValue] = useState(""); - const [generatedInvite, setDisplayGeneratedInvite] = useState(""); - const [isInviteCopied, setIsInviteCopied] = useState(false); + const [keyValue, setKeyValue] = useState("") + const [generatedInvite, setDisplayGeneratedInvite] = useState("") + const [isInviteCopied, setIsInviteCopied] = useState(false) //@ts-ignore - const currentActiveRoom: Room = useAppSelector( - state => state.ChatReducer.currentActiveRoom - ); + const currentActiveRoom: Room = useAppSelector( + (state) => state.ChatReducer.currentActiveRoom + ) const handleInviteCopying = () => { - setIsInviteCopied(true); + setIsInviteCopied(true) navigator.clipboard.writeText(generatedInvite).then(() => { - setTimeout(() => setIsInviteCopied(false), 4000); - }); - }; + setTimeout(() => setIsInviteCopied(false), 4000) + }) + } - const handleGenerateInvite = async () => { + const handleGenerateInvite = async () => { try { - await invite_private_room(currentActiveRoom?.id, keyValue).then(invite => setDisplayGeneratedInvite(invite) - ); + await invite_private_room(currentActiveRoom?.id, keyValue).then( + (invite) => setDisplayGeneratedInvite(invite) + ) } catch (error) { console.log(error) } - }; + } return ( @@ -121,7 +122,7 @@ const InviteModal = ({ )} - ); -}; + ) +} -export default InviteModal; +export default InviteModal diff --git a/app/src/components/Modals/privateRoomModal.tsx b/app/src/components/Modals/privateRoomModal.tsx index a46d9f9..9b6c82d 100644 --- a/app/src/components/Modals/privateRoomModal.tsx +++ b/app/src/components/Modals/privateRoomModal.tsx @@ -1,9 +1,9 @@ -import { useState } from "react"; -import { Modal, ModalHeader, ModalBody, ModalFooter } from "reactstrap"; -import styled from "styled-components"; -import * as Colors from "../../constants/colors"; -import { joinPrivateRoomAction } from "../../redux/actions/actionCreator"; -import { useAppDispatch } from "../../redux/hooks/useAppDispatch"; +import { useState } from "react" +import { Modal, ModalHeader, ModalBody, ModalFooter } from "reactstrap" +import styled from "styled-components" +import * as Colors from "../../constants/colors" +import { joinPrivateRoomAction } from "../../redux/actions/actionCreator" +import { useAppDispatch } from "../../redux/hooks/useAppDispatch" const StyledTextarea = styled.textarea` border: 1px solid #f0f2f5; @@ -17,7 +17,7 @@ const StyledTextarea = styled.textarea` &:active { outline: none; } -`; +` const StyledButton = styled.button` background: ${Colors.ANATRACITE}; @@ -32,26 +32,26 @@ const StyledButton = styled.button` box-shadow: 0px 0px 15px 0px ${Colors.ANATRACITE}; } width: 200px; -`; +` const JoinPrivateRoomModal = ({ toggleJoinPrivateRoom, setToggleJoinPrivateRoom }: any) => { - const [roomInvite, setRoomInvite] = useState(""); - const dispatch = useAppDispatch(); + const [roomInvite, setRoomInvite] = useState("") + const dispatch = useAppDispatch() const handleModalClosing = () => { - setRoomInvite(""); - setToggleJoinPrivateRoom(false); - }; + setRoomInvite("") + setToggleJoinPrivateRoom(false) + } const handleRoomCreation = () => { if (roomInvite) { - dispatch(joinPrivateRoomAction(roomInvite)); - handleModalClosing(); + dispatch(joinPrivateRoomAction(roomInvite)) + handleModalClosing() } - }; + } return ( @@ -60,7 +60,7 @@ const JoinPrivateRoomModal = ({ setRoomInvite(e.target.value)} + onChange={(e) => setRoomInvite(e.target.value)} rows={2} /> @@ -70,7 +70,7 @@ const JoinPrivateRoomModal = ({ - ); -}; + ) +} -export default JoinPrivateRoomModal; +export default JoinPrivateRoomModal diff --git a/app/src/components/Modals/recoverModal.tsx b/app/src/components/Modals/recoverModal.tsx index dabc66c..5d61f46 100644 --- a/app/src/components/Modals/recoverModal.tsx +++ b/app/src/components/Modals/recoverModal.tsx @@ -1,13 +1,24 @@ +import * as Colors from "../../constants/colors"; +import styled from "styled-components"; import { useState } from "react"; import { useDispatch } from "react-redux"; import { useNavigate } from "react-router"; import { Modal, ModalBody, ModalFooter, ModalHeader } from "reactstrap"; -import { init, receive_message, recover_profile } from "rln-client-lib"; -import styled from "styled-components"; -import * as Colors from "../../constants/colors"; +import { + get_rooms, + init, + receive_message, + recover_profile +} from "rln-client-lib"; +import { IRooms } from "rln-client-lib/dist/src/profile/interfaces"; import { serverUrl, socketUrl } from "../../constants/constants"; -import { addMessageToRoomAction, getChatHistoryAction, getRoomsAction } from "../../redux/actions/actionCreator"; import { generateProof } from "../../util/util"; +import { + addMessageToRoomAction, + getRoomsAction, + loadMessagesForRooms, + runSyncMessageHistory +} from "../../redux/actions/actionCreator"; const StyledButton = styled.button` background: ${Colors.ANATRACITE}; @@ -50,21 +61,37 @@ const RecoverModal = ({ const dispatch = useDispatch(); const [userData, setUserData] = useState(""); + const loadMessagesFromDb = async () => { + const allRooms: IRooms = await get_rooms(); + const roomIds: string[] = [ + ...allRooms.direct.map(d => d.id), + ...allRooms.private.map(d => d.id), + ...allRooms.public.map(d => d.id) + ]; + + const nowTimestamp: number = new Date().getTime(); + dispatch(loadMessagesForRooms(roomIds, nowTimestamp)); + + await receive_message(receiveMessageCallback); + }; + const initializeApp = async () => { try { await init({ serverUrl: serverUrl, socketUrl: socketUrl - }, - generateProof) - .then(() => { - navigate("/dashboard"); - dispatch(getRoomsAction()); - dispatch(getChatHistoryAction()); - }) - .then(async () => { - await receive_message(receiveMessageCallback); - }); + }, generateProof).then(() => { + navigate("/dashboard"); + dispatch(getRoomsAction()); + + dispatch( + runSyncMessageHistory({ + onSuccess: () => { + loadMessagesFromDb(); + } + }) + ); + }); } catch (error) { navigate("/r-procedure"); } @@ -98,6 +125,7 @@ const RecoverModal = ({ navigate("/r-procedure"); } }; + return ( setToggleRecoverModal(false)}> diff --git a/app/src/components/Modals/trustedContactsModal.tsx b/app/src/components/Modals/trustedContactsModal.tsx index fca6ced..d8b91ad 100644 --- a/app/src/components/Modals/trustedContactsModal.tsx +++ b/app/src/components/Modals/trustedContactsModal.tsx @@ -1,10 +1,10 @@ -import { useEffect, useState } from "react"; -import { Modal, ModalBody, ModalHeader } from "reactstrap"; -import styled from "styled-components"; -import * as Colors from "../../constants/colors"; -import { faPen, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import ReactTooltip from "react-tooltip"; +import { useEffect, useState } from "react" +import { Modal, ModalBody, ModalHeader } from "reactstrap" +import styled from "styled-components" +import * as Colors from "../../constants/colors" +import { faPen, faTrash } from "@fortawesome/free-solid-svg-icons" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import ReactTooltip from "react-tooltip" import { delete_contact, get_contact, @@ -28,7 +28,7 @@ const StyledTextarea = styled.textarea` &:active { outline: none; } -`; +` const StyledInput = styled.input` border: 1px solid #f0f2f5; @@ -40,7 +40,7 @@ const StyledInput = styled.input` &:active { outline: none; } -`; +` const StyledButton = styled.button` background: ${Colors.ANATRACITE}; @@ -55,7 +55,7 @@ const StyledButton = styled.button` box-shadow: 0px 0px 15px 0px ${Colors.ANATRACITE}; } width: 200px; -`; +` const StyledContactWrapper = styled.div` display: flex; @@ -69,7 +69,7 @@ const StyledContactWrapper = styled.div` fill: ${Colors.ANATRACITE}; } } -`; +` const StyledContactName = styled.div` background: ${Colors.BERRY_PINK}; @@ -81,40 +81,40 @@ const StyledContactName = styled.div` color: white; min-width: 300px; text-align: center; -`; +` const StyledButtonWrapper = styled.div` display: flex; justify-content: center; margin-top: 20px; -`; +` const StyledInputLabel = styled.div` font-size: 12px; padding: 0 10px; color: ${Colors.ANATRACITE}; margin-top: 25px; -`; +` type TrustedContactsProps = { - setToggleTrustedContacts: (shouldToggle: boolean) => void; - toggleTrustedContacts: boolean; -}; + setToggleTrustedContacts: (shouldToggle: boolean) => void + toggleTrustedContacts: boolean +} const TrustedContactsModal = ({ - setToggleTrustedContacts, - toggleTrustedContacts + setToggleTrustedContacts, + toggleTrustedContacts }: TrustedContactsProps) => { - const [toggleAddEditModal, setToggleAddEditModal] = useState(false); - const [editContactName, setEditContactName] = useState(""); - const trustedContacts = useAppSelector( - state => state.ChatReducer.trustedContacts - ); - const dispatch = useAppDispatch(); + const [toggleAddEditModal, setToggleAddEditModal] = useState(false) + const [editContactName, setEditContactName] = useState("") + const trustedContacts = useAppSelector( + (state) => state.ChatReducer.trustedContacts + ) + const dispatch = useAppDispatch() - useEffect(() => { - dispatch(getTrustedContacts()); - }, [toggleTrustedContacts]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + dispatch(getTrustedContacts()) + }, [toggleTrustedContacts]) // eslint-disable-line react-hooks/exhaustive-deps const handleContactDeleting = (name: string) => { if (name) { @@ -187,106 +187,106 @@ const TrustedContactsModal = ({ - - - - ); -}; + + + + ) +} type AddEditModalProps = { - name?: string; - setToggleAddEditModal: (shouldToggle: boolean) => void; - toggleAddEditModal: boolean; - setEditContactName: (name: string) => void; -}; + name?: string + setToggleAddEditModal: (shouldToggle: boolean) => void + toggleAddEditModal: boolean + setEditContactName: (name: string) => void +} const AddEditContactModal = ({ - name, - setToggleAddEditModal, - toggleAddEditModal, - setEditContactName + name, + setToggleAddEditModal, + toggleAddEditModal, + setEditContactName }: AddEditModalProps) => { - const [contactName, setContactName] = useState(""); - const [publicKey, setPublicKey] = useState(""); - const dispatch = useAppDispatch(); + const [contactName, setContactName] = useState("") + const [publicKey, setPublicKey] = useState("") + const dispatch = useAppDispatch() - useEffect(() => { - if (name) { - get_contact(name) - .then(contactDetails => { - setContactName(contactDetails.name); - setPublicKey(contactDetails.publicKey); - }) - .catch(err => toast.error(err)); - } - }, [toggleAddEditModal]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (name) { + get_contact(name) + .then((contactDetails) => { + setContactName(contactDetails.name) + setPublicKey(contactDetails.publicKey) + }) + .catch((err) => toast.error(err)) + } + }, [toggleAddEditModal]) // eslint-disable-line react-hooks/exhaustive-deps - const handleContactSaving = () => { - if (contactName && publicKey) { - if (name) { - update_contact(name, contactName, publicKey) - .then(() => { - dispatch(getTrustedContacts()); - setPublicKey(""); - setContactName(""); - setEditContactName(""); - setToggleAddEditModal(false); - }) - .catch(err => toast.error(err)); - } else { - insert_contact(contactName, publicKey) - .then(() => { - dispatch(getTrustedContacts()); - setPublicKey(""); - setContactName(""); - setEditContactName(""); - setToggleAddEditModal(false); - }) - .catch(err => toast.error(err)); - } - } - }; + const handleContactSaving = () => { + if (contactName && publicKey) { + if (name) { + update_contact(name, contactName, publicKey) + .then(() => { + dispatch(getTrustedContacts()) + setPublicKey("") + setContactName("") + setEditContactName("") + setToggleAddEditModal(false) + }) + .catch((err) => toast.error(err)) + } else { + insert_contact(contactName, publicKey) + .then(() => { + dispatch(getTrustedContacts()) + setPublicKey("") + setContactName("") + setEditContactName("") + setToggleAddEditModal(false) + }) + .catch((err) => toast.error(err)) + } + } + } - return ( - - { - setContactName(""); - setPublicKey(""); - setEditContactName(""); - setToggleAddEditModal(false); - }} - > - {name ? "Edit Contact" : "Create Trusted Contact"} - - - Name - setContactName(e.target.value)} - /> + return ( + + { + setContactName("") + setPublicKey("") + setEditContactName("") + setToggleAddEditModal(false) + }} + > + {name ? "Edit Contact" : "Create Trusted Contact"} + + + Name + setContactName(e.target.value)} + /> - Public Key - setPublicKey(e.target.value)} - /> - - - {name ? "Update" : "Create"} - - - - - ); -}; + Public Key + setPublicKey(e.target.value)} + /> + + + {name ? "Update" : "Create"} + + + + + ) +} -export default TrustedContactsModal; +export default TrustedContactsModal diff --git a/app/src/components/PublicRoomInvitedScreen/index.tsx b/app/src/components/PublicRoomInvitedScreen/index.tsx index 57f877d..a3427bb 100644 --- a/app/src/components/PublicRoomInvitedScreen/index.tsx +++ b/app/src/components/PublicRoomInvitedScreen/index.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; -import { useNavigate, useParams } from "react-router"; -import { join_public_room } from "rln-client-lib"; -import styled from "styled-components"; -import * as Colors from "../../constants/colors"; +import { useState } from "react" +import { useNavigate, useParams } from "react-router" +import { join_public_room } from "rln-client-lib" +import styled from "styled-components" +import * as Colors from "../../constants/colors" const StyledButton = styled.button` background: ${Colors.BERRY_PINK}; @@ -16,32 +16,34 @@ const StyledButton = styled.button` box-shadow: 0px 0px 15px 0px ${Colors.BERRY_PINK}; } width: 180px; -`; +` const StyledInvitedScreenWrapper = styled.div` display: flex; align-items: center; justify-content: center; height: 100vh; -`; +` const StyledNotificationWrapper = styled.div` color: ${Colors.PASTEL_RED}; font-size: 24px; margin: 20px 0; -`; +` const successMessage = "Your new public room has been successfully added." const PublicRoomInvitedScreen = () => { - const params = useParams(); - const navigate = useNavigate(); - const [notificationMessage,setNotificationMessage] = useState("") + const params = useParams() + const navigate = useNavigate() + const [notificationMessage, setNotificationMessage] = useState("") - const handlePublicRoomInvite=async()=>{ - try { + const handlePublicRoomInvite = async () => { + try { //@ts-ignore - await join_public_room(params.roomId).then(() => setNotificationMessage(successMessage)) + await join_public_room(params.roomId).then(() => + setNotificationMessage(successMessage) + ) } catch (error) { setNotificationMessage(error as string) } @@ -50,25 +52,26 @@ const PublicRoomInvitedScreen = () => { return (
- {notificationMessage ? + {notificationMessage ? ( <> - + {notificationMessage} navigate("/dashboard")}> Go to Rooms - : + + ) : ( <> Would you like to join room with id: {params.roomId} ? Join - } + )}
- ); -}; + ) +} -export default PublicRoomInvitedScreen; +export default PublicRoomInvitedScreen diff --git a/app/src/components/RegisterOrRecover/index.tsx b/app/src/components/RegisterOrRecover/index.tsx index 3a38214..c7afff4 100644 --- a/app/src/components/RegisterOrRecover/index.tsx +++ b/app/src/components/RegisterOrRecover/index.tsx @@ -1,15 +1,15 @@ -import React, { useState } from "react"; -import { useNavigate } from "react-router"; -import styled from "styled-components"; -import * as Colors from "../../constants/colors"; -import RecoverModal from "../Modals/recoverModal"; -import { init, receive_message } from "rln-client-lib"; -import { useDispatch } from "react-redux"; -import { addMessageToRoomAction, getChatHistoryAction, getRoomsAction } from "../../redux/actions/actionCreator"; +import React, { useState } from "react" +import { useNavigate } from "react-router" +import styled from "styled-components" +import * as Colors from "../../constants/colors" +import RecoverModal from "../Modals/recoverModal" +import { init, receive_message } from "rln-client-lib" +import { useDispatch } from "react-redux" import { - serverUrl, - socketUrl -} from "../../constants/constants"; + addMessageToRoomAction, + getRoomsAction +} from "../../redux/actions/actionCreator" +import { serverUrl, socketUrl } from "../../constants/constants" import { generateProof } from "../../util/util"; const StyledRegisterWrapper = styled.div` @@ -17,7 +17,7 @@ const StyledRegisterWrapper = styled.div` height: 100%; display: flex; align-items: center; -`; +` const StyledButtonsContainer = styled.div` margin: 0 auto; @@ -25,10 +25,10 @@ const StyledButtonsContainer = styled.div` border-radius: 27px; display: flex; flex-direction: column; -`; +` const StyledRButton = styled.button` - background: ${props => props.color}; + background: ${(props) => props.color}; border-radius: 8px; border: none; outline: none; @@ -37,22 +37,22 @@ const StyledRButton = styled.button` color: ${Colors.ANATRACITE}; &:hover { transition: 0.15s; - box-shadow: 0px 0px 15px 0px ${props => props.color}; + box-shadow: 0px 0px 15px 0px ${(props) => props.color}; } -`; +` const RegisterOrRecover = () => { - const [toggleRecoverModal, setToggleRecoverModal] = useState(false); - const navigate = useNavigate(); - const dispatch = useDispatch(); + const [toggleRecoverModal, setToggleRecoverModal] = useState(false) + const navigate = useNavigate() + const dispatch = useDispatch() const handleRegisterClick = () => { - initializeApp(); - }; + initializeApp() + } const initializeApp = async () => { try { - const identityCommitment = await getActiveIdentity(); + const identityCommitment = await getActiveIdentity() await init( { serverUrl, @@ -60,30 +60,31 @@ const RegisterOrRecover = () => { }, generateProof, identityCommitment - ).then(() => { - navigate("/dashboard"); - dispatch(getRoomsAction()); - dispatch(getChatHistoryAction()) - }) - .then(async() => { - await receive_message(receiveMessageCallback); - }); + ) + .then(() => { + navigate("/dashboard") + dispatch(getRoomsAction()) + // No need to sync the message history on Register, because the user doesn't have any room + }) + .then(async () => { + await receive_message(receiveMessageCallback) + }) } catch (error) { - navigate("/r-procedure"); + navigate("/r-procedure") } - }; + } const getActiveIdentity = async () => { - console.log('getting the identity from zk-keeper'); + console.info("getting the identity from zk-keeper") const { injected } = window as any - const client = await injected.connect(); - const id = await client.getActiveIdentity(1, 2); - return id; + const client = await injected.connect() + const id = await client.getActiveIdentity(1, 2) + return id } const receiveMessageCallback = (message: any, roomId: string) => { - dispatch(addMessageToRoomAction(message, roomId)); - }; + dispatch(addMessageToRoomAction(message, roomId)) + } return ( @@ -105,7 +106,7 @@ const RegisterOrRecover = () => { /> - ); -}; + ) +} -export default RegisterOrRecover; +export default RegisterOrRecover diff --git a/app/src/components/RoomHandlingButtons/index.tsx b/app/src/components/RoomHandlingButtons/index.tsx index 361d695..6d9026b 100644 --- a/app/src/components/RoomHandlingButtons/index.tsx +++ b/app/src/components/RoomHandlingButtons/index.tsx @@ -1,19 +1,19 @@ -import React, { useState } from "react"; -import styled from "styled-components"; -import { faFileExport } from "@fortawesome/free-solid-svg-icons"; -import { faUserPlus } from "@fortawesome/free-solid-svg-icons"; -import { faKey } from "@fortawesome/free-solid-svg-icons"; -import { faPlusCircle, faUserLock } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import RoomOptionsModal from "../Modals"; -import ReactTooltip from "react-tooltip"; -import { saveAs } from "file-saver"; +import React, { useState } from "react" +import styled from "styled-components" +import { faFileExport } from "@fortawesome/free-solid-svg-icons" +import { faUserPlus } from "@fortawesome/free-solid-svg-icons" +import { faKey } from "@fortawesome/free-solid-svg-icons" +import { faPlusCircle, faUserLock } from "@fortawesome/free-solid-svg-icons" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import RoomOptionsModal from "../Modals" +import ReactTooltip from "react-tooltip" +import { saveAs } from "file-saver" -import * as Colors from "../../constants/colors"; -import JoinPrivateRoomModal from "../Modals/privateRoomModal"; -import TrustedContactsModal from "../Modals/trustedContactsModal"; -import GeneratePublicKeyModal from "../Modals/generatePublicKey"; -import { export_profile } from "rln-client-lib"; +import * as Colors from "../../constants/colors" +import JoinPrivateRoomModal from "../Modals/privateRoomModal" +import TrustedContactsModal from "../Modals/trustedContactsModal" +import GeneratePublicKeyModal from "../Modals/generatePublicKey" +import { export_profile } from "rln-client-lib" const StyledButtonsWrapper = styled.div` display: flex; @@ -27,26 +27,26 @@ const StyledButtonsWrapper = styled.div` fill: ${Colors.BERRY_PINK}; } } -`; +` const RoomHandlingButtons = () => { - const [toggleModal, setToggleModal] = useState(false); - const [toggleJoinPrivateRoom, setToggleJoinPrivateRoom] = useState(false); - const [toggleGeneratePublicKey, setToggleGeneratePublicKey] = useState(false); - const [toggleTrustedContacts, setToggleTrustedContacts] = useState(false); + const [toggleModal, setToggleModal] = useState(false) + const [toggleJoinPrivateRoom, setToggleJoinPrivateRoom] = useState(false) + const [toggleGeneratePublicKey, setToggleGeneratePublicKey] = useState(false) + const [toggleTrustedContacts, setToggleTrustedContacts] = useState(false) const handleExportProfileClick = async () => { try { - export_profile().then(json => { + export_profile().then((json) => { var fileToSave = new Blob([json], { type: "application/json" - }); - return saveAs(fileToSave, "Profile.json"); - }); + }) + return saveAs(fileToSave, "Profile.json") + }) } catch (error) { - console.log(error); + console.log(error) } - }; + } return ( @@ -137,7 +137,7 @@ const RoomHandlingButtons = () => { setToggleTrustedContacts={setToggleTrustedContacts} /> - ); -}; + ) +} -export default RoomHandlingButtons; +export default RoomHandlingButtons diff --git a/app/src/components/Spinner/index.tsx b/app/src/components/Spinner/index.tsx new file mode 100644 index 0000000..617ca5a --- /dev/null +++ b/app/src/components/Spinner/index.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Spinner } from "reactstrap"; +import styled from "styled-components"; + +const StyledSpinnerWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + flex-direction: column; + color: white; + div { + height: 5rem; + width: 5rem; + } +`; + +const SyncSpinner = () => ( + + + Loading messages... + +); + +export default SyncSpinner; diff --git a/app/src/constants/colors.ts b/app/src/constants/colors.ts index 1463e52..543ba55 100644 --- a/app/src/constants/colors.ts +++ b/app/src/constants/colors.ts @@ -1,5 +1,5 @@ -export const ANATRACITE = "#475C7A"; -export const BERRY_PURPLE = "#685B79"; -export const BERRY_PINK = "#AB6C82"; -export const PASTEL_RED = "#D8737F"; -export const DARK_YELLOW = "#FCBB6D"; +export const ANATRACITE = "#475C7A" +export const BERRY_PURPLE = "#685B79" +export const BERRY_PINK = "#AB6C82" +export const PASTEL_RED = "#D8737F" +export const DARK_YELLOW = "#FCBB6D" diff --git a/app/src/constants/constants.ts b/app/src/constants/constants.ts index dc1d7c9..f840c63 100644 --- a/app/src/constants/constants.ts +++ b/app/src/constants/constants.ts @@ -2,7 +2,7 @@ export const roomTypes = { public: "public", private: "private", oneOnOne: "direct" -}; +} export const identitySecret = [ "292199313053995090417273958361373808790528937259298737824851232628819755919", @@ -15,13 +15,13 @@ export const identitySecret = [ "163334061393674327105174944950480885010102997192279119202426081876815328325", "11437531512502006611249802511569299406013863708736381461713283259250234558", "216536453410511343986078619172555451653395528739428071816041036609205931217" -]; +] export const identityCommitment = - "17653365708849444179865362482568296819146357340229089950066221313927057063266"; + "17653365708849444179865362482568296819146357340229089950066221313927057063266" -export const clientUrl = `${process.env.REACT_APP_ENV}`; +export const clientUrl = `${process.env.REACT_APP_ENV}` -export const serverUrl = `${process.env.REACT_APP_SERVER_HOST}`; +export const serverUrl = `${process.env.REACT_APP_SERVER_HOST}` export const socketUrl = `${process.env.REACT_APP_SOCKET_HOST}` diff --git a/app/src/index.css b/app/src/index.css index ec2585e..4a1df4d 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -1,13 +1,13 @@ body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; } diff --git a/app/src/index.tsx b/app/src/index.tsx index ee803f5..c352c08 100644 --- a/app/src/index.tsx +++ b/app/src/index.tsx @@ -1,11 +1,11 @@ -import React from "react"; -import ReactDOM from "react-dom"; -import "./index.css"; -import App from "./App"; -import reportWebVitals from "./reportWebVitals"; -import { Provider } from "react-redux"; -import store from "./redux/store"; -import "bootstrap/dist/css/bootstrap.min.css"; +import React from "react" +import ReactDOM from "react-dom" +import "./index.css" +import App from "./App" +import reportWebVitals from "./reportWebVitals" +import { Provider } from "react-redux" +import store from "./redux/store" +import "bootstrap/dist/css/bootstrap.min.css" ReactDOM.render( @@ -14,9 +14,9 @@ ReactDOM.render( , document.getElementById("root") -); +) // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); +reportWebVitals() diff --git a/app/src/redux/actions/actionCreator.ts b/app/src/redux/actions/actionCreator.ts index 551e6b5..72cc58b 100644 --- a/app/src/redux/actions/actionCreator.ts +++ b/app/src/redux/actions/actionCreator.ts @@ -2,71 +2,105 @@ import { create_direct_room, create_private_room, create_public_room, - get_chat_history, get_rooms, join_private_room, - get_contacts -} from "rln-client-lib"; + get_contacts, + delete_messages_for_room, + get_messages_for_room, + get_messages_for_rooms, + sync_message_history +} from "rln-client-lib" +import { IMessage } from "rln-client-lib/dist/src/chat/interfaces" import { IDirectRoom, IPrivateRoom, IPublicRoom -} from "rln-client-lib/dist/src/room/interfaces"; +} from "rln-client-lib/dist/src/room/interfaces" -export type Room = IDirectRoom | IPublicRoom | IPrivateRoom; +export type Room = IDirectRoom | IPublicRoom | IPrivateRoom -export const ADD_ACTIVE_CHAT_ROOM = "ADD_ACTIVE_CHAT_ROOM"; +export const ADD_ACTIVE_CHAT_ROOM = "ADD_ACTIVE_CHAT_ROOM" export const addActiveChatRoom = (room: Room | undefined) => ({ type: ADD_ACTIVE_CHAT_ROOM, meta: room -}); +}) -export const GET_ROOMS = "GET_ROOMS"; +export const GET_ROOMS = "GET_ROOMS" export const getRoomsAction = () => ({ type: GET_ROOMS, promise: get_rooms() -}); +}) -export const CREATE_PUBLIC_ROOM = "CREATE_PUBLIC_ROOM"; +export const CREATE_PUBLIC_ROOM = "CREATE_PUBLIC_ROOM" export const createPublicRoomAction = (name: string) => ({ type: CREATE_PUBLIC_ROOM, promise: create_public_room(name) -}); +}) -export const CREATE_PRIVATE_ROOM = "CREATE_PRIVATE_ROOM"; +export const CREATE_PRIVATE_ROOM = "CREATE_PRIVATE_ROOM" export const createPrivateRoomAction = (name: string) => ({ type: CREATE_PRIVATE_ROOM, promise: create_private_room(name) -}); +}) export const joinPrivateRoomAction = (invite: string) => ({ type: CREATE_PRIVATE_ROOM, promise: join_private_room(invite) -}); +}) -export const CREATE_DIRECT_ROOM = "CREATE_DIRECT_ROOM"; +export const CREATE_DIRECT_ROOM = "CREATE_DIRECT_ROOM" export const createDirectRoomAction = ( name: string, receiver_public_key: string ) => ({ type: CREATE_DIRECT_ROOM, promise: create_direct_room(name, receiver_public_key) -}); +}) -export const ADD_MESSAGE_TO_ROOM = "ADD_MESSAGE_TO_ROOM"; -export const addMessageToRoomAction = (message: string, roomId: string) => ({ +export const ADD_MESSAGE_TO_ROOM = "ADD_MESSAGE_TO_ROOM" +export const addMessageToRoomAction = (message: IMessage, roomId: string) => ({ type: ADD_MESSAGE_TO_ROOM, meta: { message, roomId } -}); +}) -export const GET_CHAT_HISTORY = "GET_CHAT_HISTORY"; -export const getChatHistoryAction = () => ({ - type: GET_CHAT_HISTORY, - promise: get_chat_history() -}); +export const RUN_SYNC_MESSAGE_HISTORY = "RUN_SYNC_MESSAGE_HISTORY" +export const runSyncMessageHistory = (meta?: any) => ({ + type: RUN_SYNC_MESSAGE_HISTORY, + promise: sync_message_history(), + meta +}) -export const GET_TRUSTED_CONTACTS = "GET_TRUSTED_CONTACTS"; +export const LOAD_MESSAGES_FOR_ROOM = "LOAD_MESSAGES_FOR_ROOM" +export const loadMessagesForRoom = ( + roomId: string, + fromTimestamp: number, + shouldReset: boolean = false, + meta: any +) => ({ + type: LOAD_MESSAGES_FOR_ROOM, + meta: { roomId, shouldReset, ...meta }, + promise: get_messages_for_room(roomId, fromTimestamp) +}) + +export const LOAD_MESSAGES_FOR_ROOMS = "LOAD_MESSAGES_FOR_ROOMS" +export const loadMessagesForRooms = ( + roomIds: string[], + fromTimestamp: number +) => ({ + type: LOAD_MESSAGES_FOR_ROOMS, + meta: { roomIds }, + promise: get_messages_for_rooms(roomIds, fromTimestamp) +}) + +export const DELETE_MESSAGES_FOR_ROOM = "DELETE_MESSAGES_FOR_ROOM" +export const deleteMessagesForRoom = (roomId: string) => ({ + type: DELETE_MESSAGES_FOR_ROOM, + meta: { roomId }, + promise: delete_messages_for_room(roomId) +}) + +export const GET_TRUSTED_CONTACTS = "GET_TRUSTED_CONTACTS" export const getTrustedContacts = () => ({ type: GET_TRUSTED_CONTACTS, promise: get_contacts() -}); \ No newline at end of file +}) diff --git a/app/src/redux/actions/actionTypes.ts b/app/src/redux/actions/actionTypes.ts index 8cec2e9..336ce12 100644 --- a/app/src/redux/actions/actionTypes.ts +++ b/app/src/redux/actions/actionTypes.ts @@ -1 +1 @@ -export {}; \ No newline at end of file +export {} diff --git a/app/src/redux/history/index.ts b/app/src/redux/history/index.ts index 30eab10..53e9619 100644 --- a/app/src/redux/history/index.ts +++ b/app/src/redux/history/index.ts @@ -1,3 +1,3 @@ -import { createBrowserHistory } from "history"; +import { createBrowserHistory } from "history" -export default createBrowserHistory(); \ No newline at end of file +export default createBrowserHistory() diff --git a/app/src/redux/hooks/useAppDispatch/index.ts b/app/src/redux/hooks/useAppDispatch/index.ts index 82396ba..8af1b09 100644 --- a/app/src/redux/hooks/useAppDispatch/index.ts +++ b/app/src/redux/hooks/useAppDispatch/index.ts @@ -1,6 +1,7 @@ -import { Dispatch } from "react"; -import { useDispatch } from "react-redux"; -import { AnyAction } from "redux"; -import { AppDispatch } from "../../store"; +import { Dispatch } from "react" +import { useDispatch } from "react-redux" +import { AnyAction } from "redux" +import { AppDispatch } from "../../store" -export const useAppDispatch = (): Dispatch => useDispatch(); +export const useAppDispatch = (): Dispatch => + useDispatch() diff --git a/app/src/redux/hooks/useAppSelector/index.ts b/app/src/redux/hooks/useAppSelector/index.ts index 293330e..b92b4ed 100644 --- a/app/src/redux/hooks/useAppSelector/index.ts +++ b/app/src/redux/hooks/useAppSelector/index.ts @@ -1,4 +1,4 @@ -import { TypedUseSelectorHook, useSelector } from "react-redux"; -import { RootState } from "../../store"; +import { TypedUseSelectorHook, useSelector } from "react-redux" +import { RootState } from "../../store" -export const useAppSelector: TypedUseSelectorHook = useSelector; +export const useAppSelector: TypedUseSelectorHook = useSelector diff --git a/app/src/redux/reducers/chat.ts b/app/src/redux/reducers/chat.ts index 0a99ca1..c458955 100644 --- a/app/src/redux/reducers/chat.ts +++ b/app/src/redux/reducers/chat.ts @@ -1,5 +1,5 @@ -import { handle } from "redux-pack"; -import { AnyAction } from "redux"; +import { handle } from "redux-pack" +import { AnyAction } from "redux" import { ADD_ACTIVE_CHAT_ROOM, GET_ROOMS, @@ -8,49 +8,59 @@ import { CREATE_DIRECT_ROOM, Room, ADD_MESSAGE_TO_ROOM, - GET_CHAT_HISTORY, - GET_TRUSTED_CONTACTS + GET_TRUSTED_CONTACTS, + DELETE_MESSAGES_FOR_ROOM, + LOAD_MESSAGES_FOR_ROOM, + LOAD_MESSAGES_FOR_ROOMS, + RUN_SYNC_MESSAGE_HISTORY } from "../actions/actionCreator"; import { IRooms, ITrustedContact } from "rln-client-lib/dist/src/profile/interfaces"; +import { IMessage } from "rln-client-lib/dist/src/chat/interfaces"; interface RoomsState { rooms: IRooms; currentActiveRoom: Room | undefined; chatHistory: Messages; trustedContacts: ITrustedContact[]; + chatHistorySyncing: boolean + stayOnBottom: boolean + loading: boolean } export type Messages = { - [id: string]: any[]; -}; + [id: string]: IMessage[] +} const defaultState: RoomsState = { rooms: { public: [], private: [], direct: [] }, currentActiveRoom: undefined, chatHistory: {}, - trustedContacts: [] -}; + trustedContacts: [], + chatHistorySyncing: false, + stayOnBottom: true, + loading: false +} const ChatReducer = (state = defaultState, action: AnyAction): RoomsState => { - const { type, payload, meta } = action; + const { type, payload, meta } = action switch (type) { case GET_ROOMS: { return handle(state, action, { - start: prevState => ({ ...prevState }), - success: prevState => ({ + start: (prevState) => ({ ...prevState }), + success: (prevState) => ({ ...prevState, rooms: payload }), - finish: prevState => ({ ...prevState }) - }); + finish: (prevState) => ({ ...prevState }) + }) } case CREATE_PUBLIC_ROOM: { return handle(state, action, { - start: prevState => ({ ...prevState }), - success: prevState => ({ + start: (prevState) => ({ ...prevState }), + success: (prevState) => ({ ...prevState, rooms: { ...prevState.rooms, @@ -58,13 +68,13 @@ const ChatReducer = (state = defaultState, action: AnyAction): RoomsState => { }, currentActiveRoom: payload }), - finish: prevState => ({ ...prevState }) - }); + finish: (prevState) => ({ ...prevState }) + }) } case CREATE_PRIVATE_ROOM: { return handle(state, action, { - start: prevState => ({ ...prevState }), - success: prevState => ({ + start: (prevState) => ({ ...prevState }), + success: (prevState) => ({ ...prevState, rooms: { ...prevState.rooms, @@ -72,13 +82,13 @@ const ChatReducer = (state = defaultState, action: AnyAction): RoomsState => { }, currentActiveRoom: payload }), - finish: prevState => ({ ...prevState }) - }); + finish: (prevState) => ({ ...prevState }) + }) } case CREATE_DIRECT_ROOM: { return handle(state, action, { - start: prevState => ({ ...prevState }), - success: prevState => ({ + start: (prevState) => ({ ...prevState }), + success: (prevState) => ({ ...prevState, rooms: { ...prevState.rooms, @@ -86,15 +96,15 @@ const ChatReducer = (state = defaultState, action: AnyAction): RoomsState => { }, currentActiveRoom: payload }), - finish: prevState => ({ ...prevState }) - }); + finish: (prevState) => ({ ...prevState }) + }) } case ADD_ACTIVE_CHAT_ROOM: { return { ...state, currentActiveRoom: meta - }; + } } case ADD_MESSAGE_TO_ROOM: { @@ -105,35 +115,81 @@ const ChatReducer = (state = defaultState, action: AnyAction): RoomsState => { [meta.roomId]: state.chatHistory[meta.roomId] ? [...state.chatHistory[meta.roomId], meta.message] : [meta.message] - } - }; + }, + stayOnBottom: true + } + } + + case RUN_SYNC_MESSAGE_HISTORY: { + return handle(state, action, { + start: (prevState) => ({ + ...prevState, + chatHistorySyncing: true + }), + success: (prevState) => ({ ...prevState }), + finish: (prevState) => ({ + ...prevState, + chatHistorySyncing: false + }) + }) + } + + case LOAD_MESSAGES_FOR_ROOM: { + return handle(state, action, { + start: (prevState) => ({ ...prevState, loading: true }), + success: (prevState) => ({ + ...prevState, + chatHistory: { + ...prevState.chatHistory, + [meta.roomId]: meta.shouldReset + ? [...payload] + : [...payload, ...prevState.chatHistory[meta.roomId]] + }, + stayOnBottom: false + }), + finish: (prevState) => ({ ...prevState, loading: false }) + }) + } + + case LOAD_MESSAGES_FOR_ROOMS: { + return handle(state, action, { + start: (prevState) => ({ ...prevState }), + success: (prevState) => ({ + ...prevState, + chatHistory: { + ...prevState.chatHistory, + ...payload + } + }), + finish: (prevState) => ({ ...prevState }) + }) } - case GET_CHAT_HISTORY: { + case DELETE_MESSAGES_FOR_ROOM: { return handle(state, action, { - start: prevState => ({ ...prevState }), - success: prevState => ({ + start: (prevState) => ({ ...prevState }), + success: (prevState) => ({ ...prevState, - chatHistory: payload + [meta.roomId]: [] }), - finish: prevState => ({ ...prevState }) - }); + finish: (prevState) => ({ ...prevState }) + }) } case GET_TRUSTED_CONTACTS: { return handle(state, action, { - start: prevState => ({ ...prevState }), - success: prevState => ({ + start: (prevState) => ({ ...prevState }), + success: (prevState) => ({ ...prevState, trustedContacts: Object.values(payload) }), - finish: prevState => ({ ...prevState }) - }); + finish: (prevState) => ({ ...prevState }) + }) } default: - return state; + return state } -}; +} -export default ChatReducer; +export default ChatReducer diff --git a/app/src/redux/reducers/index.ts b/app/src/redux/reducers/index.ts index d901004..8307885 100644 --- a/app/src/redux/reducers/index.ts +++ b/app/src/redux/reducers/index.ts @@ -1,15 +1,12 @@ -import { AnyAction, combineReducers } from "redux"; -import ChatReducer from "./chat"; +import { AnyAction, combineReducers } from "redux" +import ChatReducer from "./chat" const appReducer = combineReducers({ ChatReducer -}); +}) const rootReducer = (state: any, action: AnyAction) => { - // if (action.type === RESET_APP_STORE) { - // state = undefined; - // } - return appReducer(state, action); -}; + return appReducer(state, action) +} -export default rootReducer; +export default rootReducer diff --git a/app/src/redux/store/index.ts b/app/src/redux/store/index.ts index e682bf1..743af12 100644 --- a/app/src/redux/store/index.ts +++ b/app/src/redux/store/index.ts @@ -1,19 +1,19 @@ -import { applyMiddleware, createStore } from "redux"; -import thunk from "redux-thunk"; -import rootReducer from "../reducers"; -import { composeWithDevTools } from "redux-devtools-extension/developmentOnly"; -import { middleware as reduxPackMiddleware } from "redux-pack"; +import { applyMiddleware, createStore } from "redux" +import thunk from "redux-thunk" +import rootReducer from "../reducers" +import { composeWithDevTools } from "redux-devtools-extension/developmentOnly" +import { middleware as reduxPackMiddleware } from "redux-pack" -const initialState = {}; +const initialState = {} const store = createStore( rootReducer, initialState, composeWithDevTools(applyMiddleware(...[thunk, reduxPackMiddleware])) -); +) -export default store; +export default store // Infer the `RootState` and `AppDispatch` types from the store itself -export type RootState = ReturnType; -export type AppDispatch = typeof store.dispatch; +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch diff --git a/app/src/reportWebVitals.ts b/app/src/reportWebVitals.ts index 49a2a16..8c8400b 100644 --- a/app/src/reportWebVitals.ts +++ b/app/src/reportWebVitals.ts @@ -1,15 +1,15 @@ -import { ReportHandler } from 'web-vitals'; +import { ReportHandler } from "web-vitals" const reportWebVitals = (onPerfEntry?: ReportHandler) => { if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); - }); + import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry) + getFID(onPerfEntry) + getFCP(onPerfEntry) + getLCP(onPerfEntry) + getTTFB(onPerfEntry) + }) } -}; +} -export default reportWebVitals; +export default reportWebVitals diff --git a/app/src/setupTests.ts b/app/src/setupTests.ts index 8f2609b..6a0fd12 100644 --- a/app/src/setupTests.ts +++ b/app/src/setupTests.ts @@ -2,4 +2,4 @@ // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom'; +import "@testing-library/jest-dom" diff --git a/rln-client-lib/jest.config.js b/rln-client-lib/jest.config.js index 5dd918d..d5e8ccb 100644 --- a/rln-client-lib/jest.config.js +++ b/rln-client-lib/jest.config.js @@ -19,7 +19,10 @@ module.exports = { '!src/communication/websocket.ts', '!src/hasher.ts' ], - setupFilesAfterEnv: ['./tests/jest.setup.ts'], + setupFilesAfterEnv: [ + './tests/jest.setup.ts', + 'fake-indexeddb/auto' + ], coverageThreshold: { 'global': { "branches": 80, diff --git a/rln-client-lib/package.json b/rln-client-lib/package.json index 7269de8..8e48a58 100644 --- a/rln-client-lib/package.json +++ b/rln-client-lib/package.json @@ -10,6 +10,7 @@ "@zk-kit/protocols": "^1.8.2", "axios": "^0.24.0", "crypto-js": "^4.1.1", + "dexie": "^3.2.1", "dotenv": "^10.0.0", "uuid": "^8.3.2", "ws": "^8.3.0" @@ -23,6 +24,7 @@ "@types/node": "^16.11.12", "@types/uuid": "^8.3.3", "babel-jest": "26.6.0", + "fake-indexeddb": "^3.1.7", "jest": "27.0.3", "jest-localstorage-mock": "^2.4.18", "mockdate": "^3.0.5", diff --git a/rln-client-lib/src/chat/db.ts b/rln-client-lib/src/chat/db.ts new file mode 100644 index 0000000..09c3d48 --- /dev/null +++ b/rln-client-lib/src/chat/db.ts @@ -0,0 +1,113 @@ +import Dexie from 'dexie'; +import { IChatHistoryDB, IMessage } from "./interfaces"; + +/** + * Uses IndexedDB as a persistent database to store all the user's messages in the browser, while using {@link Dexie} as + * an abstraction layer for interacting with IndexedDB. + */ +export class LocalChatDB implements IChatHistoryDB { + + public static MAX_NUMBER_OF_MESSAGES_TO_FETCH_FROM_DB = 100; + + public static DB_NAME = "ZKChatHistory"; + private db: ZKChatDB; + + constructor() { + this.db = new ZKChatDB(); + } + + /** + * Save message to IndexedDB. + */ + public async saveMessage(roomId: string, message: IMessage) { + await this.db.messages.put({ + uuid: message.uuid, + roomId: roomId, + epoch: message.epoch, + chat_type: message.chat_type, + message_content: message.message_content + }) + } + + /** + * Returns messages for a given room with a maximum limit of {@link LocalChatDB.MAX_NUMBER_OF_MESSAGES_TO_FETCH_FROM_DB}, + * ordered in reverse starting from the message with the nearest epoch to the provided {@link fromTimestamp}. + */ + public async getMessagesForRoom(roomId: string, fromTimestamp: number): Promise { + const messages: IZkMessage[] = await this.db.messages + .orderBy('epoch') + .reverse() + .and(message => { + return message.roomId == roomId && new Date(message.epoch) < new Date(fromTimestamp) + }) + .limit(LocalChatDB.MAX_NUMBER_OF_MESSAGES_TO_FETCH_FROM_DB) + .toArray(); + + return messages.map(message => { + return { + uuid: message.uuid, + epoch: message.epoch, + chat_type: message.chat_type, + message_content: message.message_content + } + }); + + } + + /** + * Returns messages for the given rooms with a maximum limit of {@link LocalChatDB.MAX_NUMBER_OF_MESSAGES_TO_FETCH_FROM_DB}, + * ordered in reverse starting from the message with the nearest epoch to the provided {@link fromTimestamp}. + */ + public async getMessagesForRooms(roomIds: string[], fromTimestamp: number): Promise<{ [key: string]: IMessage[] }> { + const messagesForRooms: { [key: string]: IMessage[] } = {}; + + for (let rId of roomIds) { + const roomMessages: IMessage[] = await this.getMessagesForRoom(rId, fromTimestamp); + messagesForRooms[rId] = roomMessages; + } + + return messagesForRooms; + } + + /** + * From all the stored messages in IndexedDB, this method returns the maximum timestamp. In case no messages are found in the local database, -1 is returned. + */ + public async getMaxTimestampForAllMessages(): Promise { + const mostRecentMessage = await this.db.messages.orderBy('epoch').reverse().first(); + + if (mostRecentMessage != undefined) { + return mostRecentMessage.epoch; + } + + return -1; + } + + /** + * Deletes all messages for a given room. + */ + public async deleteAllMessagesForRoom(roomId: string) { + await this.db.messages.where('roomId').equals(roomId).delete(); + } +} + +export class ZKChatDB extends Dexie { + + messages!: Dexie.Table; + + constructor() { + super(LocalChatDB.DB_NAME); + + this.version(1).stores({ + messages: '++id, roomId, epoch' + }); + } +} + +interface IZkMessage { + id?: string; + uuid: string; + roomId: string; + epoch: number; + chat_type: string; + message_content: string; +} \ No newline at end of file diff --git a/rln-client-lib/src/chat/index.ts b/rln-client-lib/src/chat/index.ts index ad9868f..5cd1077 100644 --- a/rln-client-lib/src/chat/index.ts +++ b/rln-client-lib/src/chat/index.ts @@ -1,8 +1,9 @@ +import { RLNFullProof } from "@zk-kit/protocols"; +import { IChatHistoryDB, IMessage, ITimeRangeMessages } from './interfaces'; import { ICryptography } from '../crypto/interfaces'; import { ServerCommunication } from '../communication/index'; import ProfileManager from "../profile"; import Hasher from "../hasher"; -import { RLNFullProof } from "@zk-kit/protocols"; /** * The core component that is responsible for creating valid ZK proofs for a message, encrypting and dispatching it, as well as receiving and decrypting messages @@ -16,11 +17,14 @@ class ChatManager { private profile_manager: ProfileManager; private communication_manager: ServerCommunication; private cryptography: ICryptography; + private message_db: IChatHistoryDB; private hasher: Hasher; private message_callback; + private chatHistoryIsSyncing: boolean = false; + /** * Same RLN identifier as the one in server. */ @@ -28,10 +32,11 @@ class ChatManager { public static NUM_SHARES: number = 2; - constructor(profile_manager: ProfileManager, communication_manager: ServerCommunication, cryptography: ICryptography) { + constructor(profile_manager: ProfileManager, communication_manager: ServerCommunication, cryptography: ICryptography, message_db: IChatHistoryDB) { this.profile_manager = profile_manager; this.communication_manager = communication_manager; this.cryptography = cryptography; + this.message_db = message_db; this.hasher = new Hasher(); } @@ -89,7 +94,7 @@ class ChatManager { this.communication_manager.sendMessage(JSON.stringify(message)); } - public async registerReceiveMessageHandler(receive_msg_callback: (message: any, chat_room_id: string) => void) { + public async registerReceiveMessageHandler(receive_msg_callback: (message: IMessage, chat_room_id: string) => void) { this.message_callback = receive_msg_callback; this.communication_manager.receiveMessage(this.messageHandlerForRooms.bind(this)) } @@ -98,11 +103,19 @@ class ChatManager { const [decryptedMessage, room_id] = await this.decryptMessage(JSON.parse(message)); if (decryptedMessage != null && room_id != null) { - this.message_callback(decryptedMessage, room_id); + // Save the message to the local DB. + this.message_db.saveMessage(room_id, decryptedMessage); + + // When the chat history sync in underway, the callback function + // (which would indicate additional interaction with the client) is not called. + if (!this.chatHistoryIsSyncing) { + // Return the message to the calling function, usually the UI app, only if history sync is not in progress. + this.message_callback(decryptedMessage, room_id); + } } } - public async decryptMessage(message: any): Promise { + public async decryptMessage(message: IMessage): Promise<[IMessage | null, string | null]> { const room_type = message.chat_type; const user_rooms_for_type: any[] = await this.profile_manager.getUserRoomsForChatType(room_type); @@ -129,6 +142,84 @@ class ChatManager { return [null, null]; } + /** + * Loads all messages from the server, from the max timestamp of the messages stored locally until the + * provided toTimestamp, and only stores locally the messages that can be decrypted. + * + * A time-range pagination is implemented, where the server returns a number of messages, no more than a certain limit in a single call. + * The pagination loop ends when the number of returned messages is less than the limit. + */ + public async syncMessagesForAllRooms(toTimestamp: number): Promise { + // The messages will be loaded starting from MAX_TIMESTAMP of the stored messages + 1ms. + let fromTimestamp: number = await this.message_db.getMaxTimestampForAllMessages() + 1; + + if (fromTimestamp == -1) { + fromTimestamp = toTimestamp - 24 * 60 * 60 * 100; + // If there are no messages stored locally, load only message history for the given day. + } + + console.info("Syncing chat history"); + this.chatHistoryIsSyncing = true; + let messageData: ITimeRangeMessages | null = await this.getAndSaveMessagesForTimeRange(fromTimestamp, toTimestamp); + + while (1) { + // The server can be unavailable and the returned payload can be null. + if (messageData == null) { + break; + } + + if (messageData.messages.length == messageData.limit) { + fromTimestamp = parseInt(messageData.returnedToTimestamp) + 1; + messageData = await this.getAndSaveMessagesForTimeRange(fromTimestamp, toTimestamp); + } else { + break; + } + } + + console.info("Chat history synced"); + this.chatHistoryIsSyncing = false; + } + + private async getAndSaveMessagesForTimeRange(fromTimestamp: number, toTimestamp: number): Promise { + let messageData: ITimeRangeMessages = await this.communication_manager.getTimeRangeChatHistory(fromTimestamp, toTimestamp); + + if (messageData == null || messageData == undefined) { + return null; + } + + for (let message of messageData.messages) { + const [decryptedMessage, room_id] = await this.decryptMessage(message); + + if (decryptedMessage != null && room_id != null) { + // Save the decrypted message to the local DB. + this.message_db.saveMessage(room_id, decryptedMessage); + } + } + + return messageData; + } + + /** + * Removes all messages for a given room from the local database. + */ + public async deleteMessageHistoryForRoom(roomId: string) { + await this.message_db.deleteAllMessagesForRoom(roomId); + } + + /** + * Returns the messages with a timestamp lower than fromTimestamp (the returned results are limited) for a given room id. + */ + public async loadMessagesForRoom(roomId: string, fromTimestamp: number): Promise { + return this.message_db.getMessagesForRoom(roomId, fromTimestamp); + } + + /** + * Returns the messages with a timestamp lower than fromTimestamp (the returned results are limited) for the given room ids. + */ + public async loadMessagesForRooms(roomIds: string[], fromTimestamp: number): Promise<{ [key: string]: IMessage[] }> { + return this.message_db.getMessagesForRooms(roomIds, fromTimestamp); + } + /** * Refresh root hash and auth path when needed, only if the root is obsolete. */ diff --git a/rln-client-lib/src/chat/interfaces.ts b/rln-client-lib/src/chat/interfaces.ts new file mode 100644 index 0000000..0b4d8ce --- /dev/null +++ b/rln-client-lib/src/chat/interfaces.ts @@ -0,0 +1,55 @@ +/** + * Indicates an infite persistant storage used to store the decrypted messages locally. + */ +export interface IChatHistoryDB { + + /** + * Save the decrypted message for the given room. + */ + saveMessage(roomId: string, message: IMessage); + + /** + * Return all messages for a room with timestamps lower than the provided {@link fromTimestamp} up to a certain limit. + * This method is used to ensure time-based pagination. + */ + getMessagesForRoom(roomId: string, fromTimestamp: number): Promise; + + /** + * Return all messages for the given rooms with timestamps lower than the provided {@link fromTimestamp} up to a certain limit. + * This method is used to ensure time-based pagination. + */ + getMessagesForRooms(roomIds: string[], fromTimestamp: number): Promise<{ [key: string]: IMessage[] }>; + + /** + * Returns the maximum timestamp from all the stored messages. + */ + getMaxTimestampForAllMessages(): Promise; + + /** + * Deletes all messages for a given room. + */ + deleteAllMessagesForRoom(roomId: string); + +} + +/** + * Core interface of the messages that are broadcast by the server. This is the type of all messages received by clients. + */ +export interface IMessage { + uuid: string; + epoch: number; + chat_type: string; + message_content: string; +} + +/** + * Core interface of the message returned by the server that contains time-based paginated messages. + */ +export interface ITimeRangeMessages { + requestedFromTimestamp: string; + requestedToTimestamp: string; + returnedFromTimestamp: string; + returnedToTimestamp: string; + messages: IMessage[]; + limit: number; +} \ No newline at end of file diff --git a/rln-client-lib/src/communication/api.ts b/rln-client-lib/src/communication/api.ts index 7b5b8d8..e8cd127 100644 --- a/rln-client-lib/src/communication/api.ts +++ b/rln-client-lib/src/communication/api.ts @@ -81,6 +81,26 @@ class RLNServerApi { } }; + public getTimeRangeChatHistory = async (fromTimestamp: number, toTimestamp: number): Promise => { + try { + const res = await axios({ + method: 'POST', + url: this.server_url + "/api/v1/chat/time_range_chat_history", + data: { + from: fromTimestamp, + to: toTimestamp + }, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + }); + return res.data; + } catch (e) { + return null; + } + }; + public getRlnRoot = async (): Promise => { try { const res = await axios({ diff --git a/rln-client-lib/src/communication/index.ts b/rln-client-lib/src/communication/index.ts index ba4329f..62a2846 100644 --- a/rln-client-lib/src/communication/index.ts +++ b/rln-client-lib/src/communication/index.ts @@ -47,6 +47,10 @@ export class ServerCommunication { return await this.rln_server.getChatHistory(); } + public async getTimeRangeChatHistory(from: number, to: number) { + return await this.rln_server.getTimeRangeChatHistory(from, to); + } + public async getRlnRoot() { return await this.rln_server.getRlnRoot(); } diff --git a/rln-client-lib/src/index.ts b/rln-client-lib/src/index.ts index 6e026b3..6959d84 100644 --- a/rln-client-lib/src/index.ts +++ b/rln-client-lib/src/index.ts @@ -15,6 +15,8 @@ import { IPrivateRoom, IDirectRoom } from './room/interfaces'; import WebsocketClient from './communication/websocket'; import WsSocketClient from './communication/ws-socket'; import KeyExchangeManager from './key-exchange'; +import { IChatHistoryDB, IMessage } from './chat/interfaces'; +import { LocalChatDB } from './chat/db'; let communication: ServerCommunication | null = null; let generated_storage_provider: StorageProvider | null = null; @@ -22,6 +24,7 @@ let generated_cryptography: ICryptography | null = null; let profile_manager: ProfileManager | null = null; let key_exchange_manager: KeyExchangeManager | null = null; let chat_manager: ChatManager; +let message_db: IChatHistoryDB; let get_proof_callback: (nullifier: string, signal: string, storage_artifacts: any, rln_identitifer: any) => Promise ; @@ -43,6 +46,10 @@ const init = async ( communication = new ServerCommunication(new RLNServerApi(server_config.serverUrl), socket_client); await communication.init(); } + + if (message_db == null) { + message_db = new LocalChatDB(); + } if (storage_provider) { generated_storage_provider = storage_provider; @@ -57,7 +64,7 @@ const init = async ( } profile_manager = new ProfileManager(generated_storage_provider, generated_cryptography); - chat_manager = new ChatManager(profile_manager, communication, generated_cryptography); + chat_manager = new ChatManager(profile_manager, communication, generated_cryptography, message_db); const root: string = await communication.getRlnRoot(); const leaves: string[] = await communication.getLeaves(); @@ -98,7 +105,7 @@ const send_message = async (chat_room_id: string, raw_message: string) => { await chat_manager.sendMessage(chat_room_id, raw_message, get_proof_callback); } -const receive_message = async(receive_msg_callback: (message: any, chat_room_id: string) => void) => { +const receive_message = async(receive_msg_callback: (message: IMessage, chat_room_id: string) => void) => { if (profile_manager == null || chat_manager == null) throw "init() not called"; @@ -247,6 +254,9 @@ const create_direct_room = async (name: string, receiver_public_key: string) => return room; } +/** + * @deprecated use persistent storage for chat history. + */ const get_chat_history = async () => { if (generated_cryptography == null || communication == null || profile_manager == null) throw "init() not called"; @@ -268,6 +278,36 @@ const get_chat_history = async () => { return groupBy(decrypted_messages, "room_id"); } +const sync_message_history = async () => { + if (generated_cryptography == null || communication == null || profile_manager == null) + throw "init() not called"; + + const timestampNow: number = new Date().getTime(); + + await chat_manager.syncMessagesForAllRooms(timestampNow); +} + +const delete_messages_for_room = async(room_id: string) => { + if (generated_cryptography == null || communication == null || profile_manager == null) + throw "init() not called"; + + await chat_manager.deleteMessageHistoryForRoom(room_id); +} + +const get_messages_for_room = async(room_id: string, from_timestamp: number) => { + if (generated_cryptography == null || communication == null || profile_manager == null) + throw "init() not called"; + + return await chat_manager.loadMessagesForRoom(room_id, from_timestamp); +} + +const get_messages_for_rooms = async (room_ids: string[], from_timestamp: number) => { + if (generated_cryptography == null || communication == null || profile_manager == null) + throw "init() not called"; + + return await chat_manager.loadMessagesForRooms(room_ids, from_timestamp); +} + const get_public_key = async() => { if (profile_manager == null) throw "init() not called"; @@ -354,6 +394,10 @@ export { update_direct_room_key, create_direct_room, get_chat_history, + sync_message_history, + delete_messages_for_room, + get_messages_for_room, + get_messages_for_rooms, get_public_key, export_profile, recover_profile, diff --git a/rln-client-lib/src/main-test.ts b/rln-client-lib/src/main-test.ts index f7a112c..3b29df8 100644 --- a/rln-client-lib/src/main-test.ts +++ b/rln-client-lib/src/main-test.ts @@ -1,5 +1,6 @@ import { Strategy, ZkIdentity } from "@zk-kit/identity"; import ChatManager from "./chat"; +import { IChatHistoryDB, IMessage } from "./chat/interfaces"; import { ServerCommunication } from "./communication"; import RLNServerApi from "./communication/api"; import SocketClient from "./communication/ws-socket"; @@ -79,13 +80,38 @@ class LocalTestCryptography implements ICryptography { } } +class LocalTestMessageDB implements IChatHistoryDB { + + async saveMessage(roomId: string, message: IMessage) { + + } + + async getMessagesForRoom(roomId: string, fromTimestamp: number): Promise { + return []; + } + + async getMessagesForRooms(roomIds: string[], fromTimestamp: number): Promise { + return {}; + } + + async getMaxTimestampForAllMessages(): Promise { + return 1; + } + + async deleteAllMessagesForRoom(roomId: string) { + + } + +} + const comm_manager = new ServerCommunication(new RLNServerApi("http://localhost:8080"), new SocketClient("ws://localhost:8081")); const cryptography = new LocalTestCryptography(); const storageProvider = new TestStorageProvider(); const profile = new ProfileManager(storageProvider, cryptography); +const message_db = new LocalTestMessageDB(); -const chat = new ChatManager(profile, comm_manager, cryptography); +const chat = new ChatManager(profile, comm_manager, cryptography, message_db); const main = async () => { let zkIdentity: ZkIdentity = new ZkIdentity(Strategy.SERIALIZED ,`{ diff --git a/rln-client-lib/tests/chat/chat.test.ts b/rln-client-lib/tests/chat/chat.test.ts index 304f20a..9882709 100644 --- a/rln-client-lib/tests/chat/chat.test.ts +++ b/rln-client-lib/tests/chat/chat.test.ts @@ -1,14 +1,16 @@ -import { StorageProvider } from '../../src/storage/interfaces'; -import { jest, test, expect, describe, beforeAll, beforeEach } from '@jest/globals' -import { ICryptography, IKeyPair } from '../../src/crypto/interfaces'; -import ProfileManager from '../../src/profile'; -import { ServerCommunication } from '../../src/communication'; +import "../../src/hasher"; import RLNServerApi from '../../src/communication/api'; import WsSocketClient from '../../src/communication/ws-socket'; import ChatManager from '../../src/chat/index'; import MockDate from 'mockdate'; - -import "../../src/hasher"; +import ProfileManager from '../../src/profile'; +import { IMessage } from './../../../server/dist/src/persistence/model/message/message.types.d'; +import { StorageProvider } from '../../src/storage/interfaces'; +import { jest, test, expect, describe, beforeAll, beforeEach } from '@jest/globals' +import { ICryptography, IKeyPair } from '../../src/crypto/interfaces'; +import { ServerCommunication } from '../../src/communication'; +import { IChatHistoryDB } from '../../src/chat/interfaces'; +import { deepClone } from '../../src/util'; const ws = require("ws"); @@ -128,6 +130,35 @@ export class LocalTestCryptography implements ICryptography { } +class LocalTestMessageDB implements IChatHistoryDB { + + private messages = {}; + + async saveMessage(roomId: string, message: IMessage) { + if (this.messages[roomId] == undefined) { + this.messages[roomId] = [deepClone(message)]; + } else { + this.messages[roomId].push(deepClone(message)); + } + } + + async getMessagesForRoom(roomId: string, fromTimestamp: number): Promise { + return this.messages[roomId]; + } + + async getMessagesForRooms(roomIdS: string[], fromTimestamp: number): Promise { + return {} + } + + async getMaxTimestampForAllMessages(): Promise { + return 0; + } + + async deleteAllMessagesForRoom(roomId: string) { + this.messages[roomId] = []; + } + +} describe('Chat test', () => { @@ -141,6 +172,7 @@ describe('Chat test', () => { let profileManager: ProfileManager; let chatManager: ChatManager; + let chatDB: IChatHistoryDB; const proof_generator_callback = async (nullifier: string, signal: string, storage_artifacts: any, rln_identitifer: any): Promise => { return JSON.stringify({ @@ -172,8 +204,9 @@ describe('Chat test', () => { server = new RLNServerApi(""); socketClient = new WsSocketClient(""); communication = new ServerCommunication(server, socketClient); + chatDB = new LocalTestMessageDB(); - chatManager = new ChatManager(profileManager, communication, crypto); + chatManager = new ChatManager(profileManager, communication, crypto, chatDB); MockDate.reset(); }); @@ -247,7 +280,12 @@ describe('Chat test', () => { test('decrypt message - no rooms', async () => { jest.spyOn(profileManager, "getUserRoomsForChatType").mockResolvedValue([]); - const decrypted = await chatManager.decryptMessage("test message"); + const decrypted = await chatManager.decryptMessage({ + chat_type: "DIRECT", + uuid: "1", + epoch: 12345, + message_content: "test content" + }); expect(decrypted).toEqual([null, null]); }); @@ -346,4 +384,147 @@ describe('Chat test', () => { expect(decrypted).toEqual([null, null]); }); + test('sync messages for all rooms', async () => { + jest.spyOn(profileManager, "getUserRoomsForChatType").mockResolvedValue([ + { + "id": "test-1", + "type": "PUBLIC", + "symmetric_key": "test_symmetric_key" + } + ]); + + jest.spyOn(communication, "getTimeRangeChatHistory").mockImplementation(async (from: number, to: number) => { + + if (from == 1) { + return { + requestedFromTimestamp: 0, + requestedToTimestamp: 10000, + returnedFromTimestamp: 0, + returnedToTimestamp: 999, + messages: [ + { + uuid: "1", + epoch: 100, + chat_type: "PUBLIC", + message_content: "content 1" + }, + { + uuid: "2", + epoch: 400, + chat_type: "PUBLIC", + message_content: "content 2" + }, + { + uuid: "3", + epoch: 500, + chat_type: "PUBLIC", + message_content: "content 3" + }, + { + uuid: "4", + epoch: 800, + chat_type: "PUBLIC", + message_content: "content 4" + }, + { + uuid: "5", + epoch: 999, + chat_type: "PUBLIC", + message_content: "content 5" + } + ], + limit: 5 + }; + } else if (from == 1000) { + return { + requestedFromTimestamp: 1000, + requestedToTimestamp: 10000, + returnedFromTimestamp: 1000, + returnedToTimestamp: 2000, + messages: [ + { + uuid: "6", + epoch: 1800, + chat_type: "PUBLIC", + message_content: "content 6" + }, + { + uuid: "7", + epoch: 2000, + chat_type: "PUBLIC", + message_content: "content 7" + } + ], + limit: 5 + }; + } + + }); + + jest.spyOn(crypto, "decryptMessageSymmetric").mockImplementation(async (cyphertext: string, symm_key: string) => { + return cyphertext; + }); + + jest.spyOn(chatDB, "getMaxTimestampForAllMessages").mockResolvedValue(0); + + const toTimestamp = 10000; + await chatManager.syncMessagesForAllRooms(toTimestamp) + + const allMessages: IMessage[] = await chatDB.getMessagesForRoom("test-1", 1); + expect(allMessages.length).toEqual(7); + + }); + + test('delete messages for a given room', async () => { + await chatDB.saveMessage('room-1', { + uuid: "1", + epoch: 100, + chat_type: "PUBLIC", + message_content: "content 1" + }); + await chatDB.saveMessage('room-1', { + uuid: "2", + epoch: 102, + chat_type: "PUBLIC", + message_content: "content 2" + }); + await chatDB.saveMessage('room-2', { + uuid: "3", + epoch: 105, + chat_type: "PUBLIC", + message_content: "content 3" + }); + + const messagesForRoomBeforeDelete: IMessage[] = await chatManager.loadMessagesForRoom('room-2', 1); + expect(messagesForRoomBeforeDelete.length).toEqual(1); + + await chatManager.deleteMessageHistoryForRoom('room-2'); + const messagesForRoomAfterDelete: IMessage[] = await chatManager.loadMessagesForRoom('room-2', 1); + expect(messagesForRoomAfterDelete.length).toEqual(0); + }); + + test('load messages for a given room', async () => { + await chatDB.saveMessage('room-1', { + uuid: "1", + epoch: 100, + chat_type: "PUBLIC", + message_content: "content 1" + }); + await chatDB.saveMessage('room-1', { + uuid: "2", + epoch: 102, + chat_type: "PUBLIC", + message_content: "content 2" + }); + await chatDB.saveMessage('room-2', { + uuid: "3", + epoch: 105, + chat_type: "PUBLIC", + message_content: "content 3" + }); + + const messages: IMessage[] = await chatManager.loadMessagesForRoom('room-1', 1); + expect(messages.length).toEqual(2); + }) + }); \ No newline at end of file diff --git a/rln-client-lib/tests/chat/db.test.ts b/rln-client-lib/tests/chat/db.test.ts new file mode 100644 index 0000000..969cd67 --- /dev/null +++ b/rln-client-lib/tests/chat/db.test.ts @@ -0,0 +1,140 @@ +import { LocalChatDB, ZKChatDB } from './../../src/chat/db'; +import { jest, test, expect, describe, beforeAll, beforeEach } from '@jest/globals' +import { IMessage } from '../../src/chat/interfaces'; + +describe('Message db test', () => { + + let chatDB: LocalChatDB; + let testChatDB: ZKChatDB; + + beforeEach(async () => { + testChatDB = new ZKChatDB(); + chatDB = new LocalChatDB(); + + await testChatDB.messages.toCollection().delete(); + }) + + test('save message', async () => { + await chatDB.saveMessage('testRoom', { + uuid: 'id-1', + epoch: 123, + chat_type: "PUBLIC", + message_content: "test message" + }) + + const messageCount = await testChatDB.messages.where('roomId').equals('testRoom').count(); + expect(messageCount).toEqual(1); + }); + + test('get messages for room - room has no messages', async () => { + const endTimestampOfMessagesInDB = (2 * LocalChatDB.MAX_NUMBER_OF_MESSAGES_TO_FETCH_FROM_DB - 1) * 10 * 1000; + const messagesForRoom: IMessage[] = await chatDB.getMessagesForRoom('testRoom', endTimestampOfMessagesInDB); + expect(messagesForRoom.length).toEqual(0); + }); + + test('get messages for room - room has more messages than limit', async () => { + await saveMessagesForRoom('testRoom', 2 * LocalChatDB.MAX_NUMBER_OF_MESSAGES_TO_FETCH_FROM_DB); + + // End timestamp would be (2 * LocalChatDB.MAX_NUMBER_OF_MESSAGES_TO_FETCH_FROM_DB - 1) * 10 * 1000; + + const endTimestampOfMessagesInDB = (2 * LocalChatDB.MAX_NUMBER_OF_MESSAGES_TO_FETCH_FROM_DB - 1) * 10 * 1000; + const messagesForRoom: IMessage[] = await chatDB.getMessagesForRoom('testRoom', endTimestampOfMessagesInDB); + expect(messagesForRoom.length).toEqual(LocalChatDB.MAX_NUMBER_OF_MESSAGES_TO_FETCH_FROM_DB); + + expect(messagesForRoom[0].epoch).toEqual(endTimestampOfMessagesInDB - 10 * 1000); + expect(messagesForRoom[99].epoch).toEqual(endTimestampOfMessagesInDB - 100 * 1000 * 10); + }); + + test('get messages for room - room has less messages than limit', async () => { + await saveMessagesForRoom('testRoom', 10); + + // End timestamp would be (10 - 1) * 10 * 1000; + + const endTimestampOfMessagesInDB = 10 * 10 * 1000; + const messagesForRoom: IMessage[] = await chatDB.getMessagesForRoom('testRoom', endTimestampOfMessagesInDB); + + expect(messagesForRoom.length).toEqual(10); + expect(messagesForRoom[0].epoch).toEqual((messagesForRoom.length - 1) * 10 * 1000); + expect(messagesForRoom[9].epoch).toEqual(0); + }); + + test('get messages for rooms - room has less messages than limit', async () => { + saveMessagesForRoom('testRoom1', 10); + saveMessagesForRoom('testRoom2', 10); + + // End timestamp would be (10 - 1) * 10 * 1000; + + const endTimestampOfMessagesInDB = 10 * 10 * 1000; + const messagesForRoom: {[key: string]: IMessage[]} = await chatDB.getMessagesForRooms(['testRoom1', 'testRoom2'], endTimestampOfMessagesInDB); + + expect(Object.keys(messagesForRoom).length).toEqual(2); + expect(Object.keys(messagesForRoom)).toContain('testRoom1'); + expect(Object.keys(messagesForRoom)).toContain('testRoom2'); + + + }); + + test('get max timestamp', async () => { + let timestamp = 0; // starting timestamp + for (let i = 0; i < 100; i++) { + await chatDB.saveMessage('testRoom', { + uuid: 'id-' + i, + epoch: timestamp, + chat_type: "PUBLIC", + message_content: "test message " + i + }); + + timestamp += 10 * 1000; // Increase timestamp by 10 seconds for every stored message + } + + const maxTimestamp = await chatDB.getMaxTimestampForAllMessages(); + + expect(maxTimestamp).toEqual((100 - 1) * 10 * 1000); + }); + + test('delete messages - room has messages', async () => { + await chatDB.saveMessage('testRoom1', { + uuid: 'id-1', + epoch: 1, + chat_type: "PUBLIC", + message_content: "test message 1" + }); + + await chatDB.saveMessage('testRoom2', { + uuid: 'id-2', + epoch: 2, + chat_type: "PUBLIC", + message_content: "test message 2" + }); + + await chatDB.saveMessage('testRoom2', { + uuid: 'id-3', + epoch: 3, + chat_type: "PUBLIC", + message_content: "test message 3" + }); + + await chatDB.deleteAllMessagesForRoom('testRoom2'); + + const messagesForRoom1: IMessage[] = await chatDB.getMessagesForRoom('testRoom1', 5); + expect(messagesForRoom1.length).toEqual(1); + + const messagesForRoom2: IMessage[] = await chatDB.getMessagesForRoom('testRoom2', 5); + expect(messagesForRoom2.length).toEqual(0); + }); + + const saveMessagesForRoom = async(roomId: string, messageCount: number) => { + let timestamp = 0; // starting timestamp + for (let i = 0; i < messageCount; i++) { + await chatDB.saveMessage(roomId, { + uuid: 'id-' + i, + epoch: timestamp, + chat_type: "PUBLIC", + message_content: "test message " + i + }); + + timestamp += 10 * 1000; // Increase timestamp by 10 seconds for every stored message + } + } + +}); \ No newline at end of file diff --git a/rln-client-lib/tests/communication/api.test.ts b/rln-client-lib/tests/communication/api.test.ts index f4eb338..be4f1e1 100644 --- a/rln-client-lib/tests/communication/api.test.ts +++ b/rln-client-lib/tests/communication/api.test.ts @@ -1,3 +1,4 @@ +import { ITimeRangeMessages } from './../../../server/src/services/chat.service'; import axios, { AxiosStatic } from 'axios' import { jest, test, expect, describe, beforeAll } from '@jest/globals' import RLNServerApi from '../../src/communication/api'; @@ -221,5 +222,53 @@ describe('Test api', () => { const res = await rlnServerApi.deleteKeyExchangeBundles(proof, "test", "test", ["1", "2"]); expect(res).toEqual(dataToReturn) }); + test('get time range message history', async() => { + const history: ITimeRangeMessages = { + requestedFromTimestamp: 0, + requestedToTimestamp: 10000, + returnedFromTimestamp: 0, + returnedToTimestamp: 999, + messages: [ + { + uuid: "1", + epoch: 100, + chat_type: "PUBLIC", + message_content: "content 1" + }, + { + uuid: "2", + epoch: 400, + chat_type: "PUBLIC", + message_content: "content 2" + }, + { + uuid: "3", + epoch: 500, + chat_type: "PUBLIC", + message_content: "content 3" + }, + { + uuid: "4", + epoch: 800, + chat_type: "PUBLIC", + message_content: "content 4" + }, + { + uuid: "5", + epoch: 999, + chat_type: "PUBLIC", + message_content: "content 5" + } + ], + limit: 5 + }; + + mockAxios.mockResolvedValue({ + data: history + }); + + const historyFromServer = await rlnServerApi.getTimeRangeChatHistory(1, 2); + expect(history).toEqual(historyFromServer); + }) }); \ No newline at end of file diff --git a/rln-client-lib/tests/communication/index.test.ts b/rln-client-lib/tests/communication/index.test.ts index e5a27e9..d02d196 100644 --- a/rln-client-lib/tests/communication/index.test.ts +++ b/rln-client-lib/tests/communication/index.test.ts @@ -128,6 +128,14 @@ describe('Test server communication', () => { expect(testSpy).toHaveBeenCalled(); }); + test('get time range chat history', async () => { + const testSpy = jest.spyOn(server, "getTimeRangeChatHistory"); + testSpy.mockResolvedValue([]); + await communication.getTimeRangeChatHistory(1, 2); + + expect(testSpy).toHaveBeenCalledWith(1, 2); + }) + test('get rln root', async () => { const testSpy = jest.spyOn(server, "getRlnRoot"); testSpy.mockResolvedValue("test root"); @@ -151,6 +159,7 @@ describe('Test server communication', () => { expect(testSpy).toHaveBeenCalled(); }); + test('save key exchange bundle', async () => { const testSpy = jest.spyOn(server, "saveKeyExchangeBundle"); diff --git a/rln-client-lib/tests/index.test.ts b/rln-client-lib/tests/index.test.ts index 275bb0a..2a3cb9e 100644 --- a/rln-client-lib/tests/index.test.ts +++ b/rln-client-lib/tests/index.test.ts @@ -16,6 +16,9 @@ import { join_private_room, create_direct_room, get_chat_history, + sync_message_history, + delete_messages_for_room, + get_messages_for_room, get_public_key, export_profile, recover_profile, @@ -159,7 +162,6 @@ class LocalTestCryptography implements ICryptography { } } - describe('Test main', () => { const proof_generator_callback = async (nullifier: string, signal: string, storage_artifacts: any, rln_identitifer: any): Promise => { @@ -210,7 +212,6 @@ describe('Test main', () => { } }); - test('init - default params, profile exists', async () => { jest.spyOn(ServerCommunication.prototype, "init").mockImplementation(() => { return new Promise((res, rej) => { res() }); @@ -577,7 +578,7 @@ describe('Test main', () => { test('get chat history', async () => { // No profile try { - await get_public_key(); + await get_chat_history(); expect(true).toBeFalsy(); } catch (e) { expect(true).toBeTruthy(); @@ -608,26 +609,30 @@ describe('Test main', () => { if (message.uuid == 'id-1') { return [{ uuid: "id-1", - message: "Decrypted 1", + chat_type: "PUBLIC", + message_content: "Decrypted 1", epoch: 1 }, 'room-1']; } if (message.uuid == 'id-2') { return [{ uuid: "id-2", - message: "Decrypted 2", + chat_type: "PUBLIC", + message_content: "Decrypted 2", epoch: 2 }, 'room-1']; } if (message.uuid == 'id-3') { return [{ uuid: "id-3", - message: "Decrypted 3", + chat_type: "PUBLIC", + message_content: "Decrypted 3", epoch: 3 }, 'room-2']; } + + return [null, null]; }) - const chatHistory = await get_chat_history(); expect(Object.keys(chatHistory)).toEqual(['room-1', 'room-2']); @@ -635,6 +640,77 @@ describe('Test main', () => { expect(chatHistory['room-2'].length).toEqual(1); }); + test('sync message history', async() => { + // No profile + try { + await sync_message_history(); + expect(true).toBeFalsy(); + } catch (e) { + expect(true).toBeTruthy(); + } + + // With profile + await init_new_profile(); + + const spy = jest.spyOn(ChatManager.prototype, "syncMessagesForAllRooms").mockResolvedValue(); + + await sync_message_history(); + + expect(spy).toHaveBeenCalled(); + }) + + test('delete messages for room', async () => { + // No profile + try { + await delete_messages_for_room("id-1"); + expect(true).toBeFalsy(); + } catch (e) { + expect(true).toBeTruthy(); + } + + // With profile + await init_new_profile(); + + const spy = jest.spyOn(ChatManager.prototype, "deleteMessageHistoryForRoom").mockResolvedValue(); + + await delete_messages_for_room("id-1"); + + expect(spy).toHaveBeenCalled(); + }) + + test('get messages for room', async () => { + // No profile + try { + await get_messages_for_room("id-1", 1); + expect(true).toBeFalsy(); + } catch (e) { + expect(true).toBeTruthy(); + } + + // With profile + await init_new_profile(); + + const spy = jest.spyOn(ChatManager.prototype, "loadMessagesForRoom").mockResolvedValue( + [ + { + uuid: "1", + epoch: 100, + chat_type: "PUBLIC", + message_content: "content 1" + }, + { + uuid: "2", + epoch: 102, + chat_type: "PUBLIC", + message_content: "content 2" + } + ]); + + await get_messages_for_room("id-1", 1); + + expect(spy).toHaveBeenCalled(); + }) + test('get public key', async () => { // No profile try { diff --git a/rln-client-lib/tests/jest.setup.ts b/rln-client-lib/tests/jest.setup.ts index ae3ce79..6c87f1a 100644 --- a/rln-client-lib/tests/jest.setup.ts +++ b/rln-client-lib/tests/jest.setup.ts @@ -1,4 +1,5 @@ -import { Crypto } from "@peculiar/webcrypto" +import { Crypto } from "@peculiar/webcrypto"; import 'jest-localstorage-mock'; global.crypto = new Crypto() + diff --git a/rln-client-lib/tests/key-exchange/key-exchange.test.ts b/rln-client-lib/tests/key-exchange/key-exchange.test.ts index 2a36f75..949d399 100644 --- a/rln-client-lib/tests/key-exchange/key-exchange.test.ts +++ b/rln-client-lib/tests/key-exchange/key-exchange.test.ts @@ -9,6 +9,8 @@ import ProfileManager from '../../src/profile'; import "../../src/hasher"; import { ICryptography, IKeyPair } from '../../src/crypto/interfaces'; import { StorageProvider } from '../../src/storage/interfaces'; +import { IChatHistoryDB, IMessage } from '../../src/chat/interfaces'; +import { deepClone } from '../../src/util'; const ws = require("ws"); @@ -128,6 +130,31 @@ export class LocalTestCryptography implements ICryptography { } +class LocalTestMessageDB implements IChatHistoryDB { + + private messages = {}; + + async saveMessage(roomId: string, message: IMessage) { + + } + + async getMessagesForRoom(roomId: string, fromTimestamp: number): Promise { + return this.messages[roomId]; + } + + async getMessagesForRooms(roomIdS: string[], fromTimestamp: number): Promise { + return {} + } + + async getMaxTimestampForAllMessages(): Promise { + return 0; + } + + async deleteAllMessagesForRoom(roomId: string) { + this.messages[roomId] = []; + } + +} describe('Key exchange test', () => { @@ -141,6 +168,7 @@ describe('Key exchange test', () => { let profileManager: ProfileManager; let storageProvider: TestStorageProvider; let cryptography: LocalTestCryptography; + let chatDB: IChatHistoryDB; let chatManager: ChatManager; beforeEach(() => { @@ -151,7 +179,8 @@ describe('Key exchange test', () => { server = new RLNServerApi(""); socketClient = new WsSocketClient(""); communication = new ServerCommunication(server, socketClient); - chatManager = new ChatManager(profileManager, communication, cryptography); + chatDB = new LocalTestMessageDB(); + chatManager = new ChatManager(profileManager, communication, cryptography, chatDB); keyExchangeManager = new KeyExchangeManager(communication, cryptography, chatManager, profileManager, proofGeneratorCallback); }) diff --git a/server/src/controllers/chat.ts b/server/src/controllers/chat.ts index e7fddb0..323e127 100644 --- a/server/src/controllers/chat.ts +++ b/server/src/controllers/chat.ts @@ -1,8 +1,12 @@ import express from "express"; +import { body, validationResult } from 'express-validator'; import { chatService } from "../services"; const router = express.Router(); +/** +* @deprecated use the endpoint to get messages by time range +*/ router.get("/chat_history", async (req, res) => { try { @@ -13,4 +17,26 @@ router.get("/chat_history", async (req, res) => { } }); +router.post("/time_range_chat_history", + body('from').notEmpty(), + body('to').notEmpty(), + async (req, res) => { + + // Finds the validation errors in this request and wraps them in an object with handy functions + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(500).json({ errors: errors.array() }); + } + + try { + const fromTimestamp = new Date(req.body.from) + const toTimestamp = new Date(req.body.to) + const data = await chatService.getMessagesInTimeRange(fromTimestamp, toTimestamp) + res.status(200).json(data); + } catch (e) { + console.log(e); + res.status(500).json(e); + } +}); + export default router; \ No newline at end of file diff --git a/server/src/interrep/api.ts b/server/src/interrep/api.ts index b44d1fb..00c2625 100644 --- a/server/src/interrep/api.ts +++ b/server/src/interrep/api.ts @@ -29,28 +29,51 @@ const getMembersForGroup = async (provider: string, name: string, limit: number const res = await axios({ method: 'GET', timeout: 5000, - url: config.INTERREP_V2 + `/groups/${provider}/${name}?members=true&limit=${limit}&offset=${offset}`, + url: config.INTERREP_V2 + `/groups/${provider}/${name}/members?limit=${limit}&offset=${offset}`, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', } }); - return res.data.data.members.map((el, index) => { + return res.data.data.map((el, index) => { return { index: offset + index, identityCommitment: el } }); } catch (e) { - console.log("Exception while loading group: ", provider, name); + console.log("Exception while loading members of the group: ", provider, name); + return []; + } +} + +/** + * Returns an ordered list of the leaf indexes of removed members in the group. + */ +const getRemovedMembersForGroup = async (provider: string, name: string, limit: number = 100, offset: number = 0): Promise => { + try { + const res = await axios({ + method: 'GET', + timeout: 5000, + url: config.INTERREP_V2 + `/groups/${provider}/${name}/removed-members?limit=${limit}&offset=${offset}`, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + }); + + return res.data.data; + } catch (e) { + console.log("Exception while loading removed members of the group: ", provider, name); return []; } } const apiFunctions = { getAllGroups, - getMembersForGroup + getMembersForGroup, + getRemovedMembersForGroup } export default apiFunctions \ No newline at end of file diff --git a/server/src/interrep/index.ts b/server/src/interrep/index.ts index 3c2e59a..e493371 100644 --- a/server/src/interrep/index.ts +++ b/server/src/interrep/index.ts @@ -46,7 +46,7 @@ class InterRepSynchronizer { let tree_root_changed = false; // 2. For each group, check the status in database. Only load new members for group if the size in db is different than the new size of the group - for (let g of allGroupsOnNet) { + for (let g of allGroupsOnNet) { const g_id = g.provider + "_" + g.name; const groupInDb: IGroup | undefined = groupsInDb.find(x => x.group_id == g_id && x.name == g.name && x.provider == g.provider); @@ -57,26 +57,43 @@ class InterRepSynchronizer { // Add all members to the tree await this.userService.appendUsers(groupMembers, g_id); // Persist the group - await this.groupService.saveGroup(g_id, g.provider, g.name, g.numberOfLeaves); + await this.groupService.saveGroup(g_id, g.provider, g.name, g.size, g.numberOfLeaves); tree_root_changed = true; } catch (e) { console.log("Unknown error while saving group", e); } } else { - // Group exists, load new members only if sizes differ (new members got added in InterRep) - if (g.numberOfLeaves > groupInDb.size) { + // Group exists locally, load new members only if the number of leaves in interep is > number of leaves stored locally + if (g.numberOfLeaves > groupInDb.number_of_leaves) { // Load members from groupInDb.size up to g.numberOfLeaves - const groupMembers: IGroupMember[] = await this.loadGroupMembersWithPagination(g.provider, g.name, groupInDb.size, g.numberOfLeaves); + const groupMembers: IGroupMember[] = await this.loadGroupMembersWithPagination(g.provider, g.name, groupInDb.number_of_leaves, g.numberOfLeaves); try { // Add group members to the tree await this.userService.appendUsers(groupMembers, g_id); - // Update group in DB - await this.groupService.updateSize(g_id, g.numberOfLeaves); + // Update group leaf count in DB + await this.groupService.updateNumberOfLeaves(g_id, g.numberOfLeaves); tree_root_changed = true; } catch (e) { - console.log("Unknown error while saving group", e); + console.log("Unknown error while saving group - appending new members", e); + } + } + + // Group exists locally, delete members that were removed from interep. + if (g.size != groupInDb.size) { + // Load all deleted indexes from interep + const indexesOfRemovedMembers: number[] = await this.loadRemovedGroupMembersWithPagination(g.provider, g.name, 0, g.size); + try { + // Remove members from the tree + await this.userService.removeUsersByIndexes(indexesOfRemovedMembers, groupInDb.group_id); + + // Update group size in DB + await this.groupService.updateSize(g_id, g.size); + + tree_root_changed = true; + } catch (e) { + console.log("Unknown error while saving group - removing deleted members", e); } } } @@ -106,6 +123,24 @@ class InterRepSynchronizer { return loadedMembers; } + private async loadRemovedGroupMembersWithPagination(provider: string, name: string, offset: number, to: number): Promise { + let indexesOfDeletedMembers: number[] = []; + + const limit: number = 100; + + let indexes: number[] = await interRepFunctions.getRemovedMembersForGroup(provider, name, limit, offset); + + indexesOfDeletedMembers = indexesOfDeletedMembers.concat(indexes); + + while (indexes.length + limit <= to) { + offset += limit; + indexes = await interRepFunctions.getRemovedMembersForGroup(provider, name, limit, offset); + indexesOfDeletedMembers = indexesOfDeletedMembers.concat(indexes); + } + + return indexesOfDeletedMembers; + } + private async continuousSync() { setInterval(async() => { console.log("Syncing with InterRep!"); diff --git a/server/src/persistence/model/group/group.schema.ts b/server/src/persistence/model/group/group.schema.ts index c9cb6f4..acb5544 100644 --- a/server/src/persistence/model/group/group.schema.ts +++ b/server/src/persistence/model/group/group.schema.ts @@ -1,16 +1,18 @@ import { Schema } from "mongoose"; -import { getAllGroups, updateGroupSize } from "./group.statics"; +import { getAllGroups, updateGroupSize, updateGroupLeafCount } from "./group.statics"; import { IGroup, IGroupDocument, IGroupModel } from "./group.types"; const GroupSchemaField: Record = { group_id: { type: String, required: true, unique: true }, provider: { type: String, required: true, unique: false }, name: { type: String, required: true, unique: false }, - size: { type: Number, required: true, unique: false } + size: { type: Number, required: true, unique: false }, + number_of_leaves: { type: Number, required: true, unique: false } }; const GroupSchema = new Schema(GroupSchemaField); GroupSchema.statics.getAllGroups = getAllGroups; GroupSchema.statics.updateGroupSize = updateGroupSize; +GroupSchema.statics.updateGroupLeafCount = updateGroupLeafCount; export default GroupSchema; diff --git a/server/src/persistence/model/group/group.statics.ts b/server/src/persistence/model/group/group.statics.ts index 4218719..80cd0fe 100644 --- a/server/src/persistence/model/group/group.statics.ts +++ b/server/src/persistence/model/group/group.statics.ts @@ -1,11 +1,14 @@ import Group from "./group.model"; import { IGroup } from "./group.types"; - export async function getAllGroups(this: typeof Group,): Promise { return this.find({}); } export async function updateGroupSize(this: typeof Group, id: string, new_size: number): Promise { return this.findOneAndUpdate({group_id: id}, {size: new_size}); +} + +export async function updateGroupLeafCount(this: typeof Group, id: string, new_leaf_count: number): Promise { + return this.findOneAndUpdate({ group_id: id }, { number_of_leaves: new_leaf_count }); } \ No newline at end of file diff --git a/server/src/persistence/model/group/group.types.ts b/server/src/persistence/model/group/group.types.ts index 6f000c1..c4db98c 100644 --- a/server/src/persistence/model/group/group.types.ts +++ b/server/src/persistence/model/group/group.types.ts @@ -1,11 +1,12 @@ import { Model, Document } from "mongoose"; -import { getAllGroups, updateGroupSize } from "./group.statics"; +import { getAllGroups, updateGroupSize, updateGroupLeafCount } from "./group.statics"; export interface IGroup { group_id: string; provider: string; name: string; size: number; + number_of_leaves: number; } export interface IGroupDocument extends IGroup, Document { } @@ -13,4 +14,5 @@ export interface IGroupDocument extends IGroup, Document { } export interface IGroupModel extends Model { getAllGroups: typeof getAllGroups; updateGroupSize: typeof updateGroupSize; + updateGroupLeafCount: typeof updateGroupLeafCount; } diff --git a/server/src/persistence/model/message/message.schema.ts b/server/src/persistence/model/message/message.schema.ts index fbb009b..422aade 100644 --- a/server/src/persistence/model/message/message.schema.ts +++ b/server/src/persistence/model/message/message.schema.ts @@ -1,6 +1,6 @@ import { IMessage, IMessageDocument, IMessageModel } from './message.types'; import { Schema } from "mongoose"; -import { getDailyMessages } from './message.statics'; +import { getDailyMessages, getMessagesInTimeRange } from './message.statics'; const MessageSchemaFields: Record = { uuid: { type: String, required: true, unique: true }, @@ -14,5 +14,6 @@ const MessageSchema = new Schema( ); MessageSchema.statics.getDailyMessages = getDailyMessages; +MessageSchema.statics.getMessagesInTimeRange = getMessagesInTimeRange; export default MessageSchema; \ No newline at end of file diff --git a/server/src/persistence/model/message/message.statics.ts b/server/src/persistence/model/message/message.statics.ts index 2c4bb8a..aaf64d9 100644 --- a/server/src/persistence/model/message/message.statics.ts +++ b/server/src/persistence/model/message/message.statics.ts @@ -1,9 +1,28 @@ +import { ITimeRangeMessages } from "../../../services/chat.service"; import Message from "./message.model"; import { IMessage } from "./message.types"; /** * @returns all the messages that were stored for the day when the function gets called + * @deprecated use the endpoint to get messages by time range */ export async function getDailyMessages(this: typeof Message,): Promise { return this.find({ epoch: { $gte: new Date(new Date().setHours(0, 0, 0, 0)).getTime() }}); } + +export async function getMessagesInTimeRange(this: typeof Message, from: Date, to: Date, limit: number): Promise { + + const foundMessagesOrdered: IMessage[] = await this.find({ epoch: { $gte: from.getTime(), $lt: to.getTime() } }, null, {limit: limit, sort: {epoch: 1}}).exec(); + + const returnedFromTimestamp: number = foundMessagesOrdered.length > 0 ? foundMessagesOrdered[0].epoch : from.getTime(); + const returnedToTimestamp: number = foundMessagesOrdered.length > 0 ? foundMessagesOrdered[foundMessagesOrdered.length - 1].epoch : to.getTime(); + + return { + requestedFromTimestamp: from.getTime(), + requestedToTimestamp: to.getTime(), + returnedFromTimestamp: returnedFromTimestamp, + returnedToTimestamp: returnedToTimestamp, + messages: foundMessagesOrdered, + limit: limit + } +} \ No newline at end of file diff --git a/server/src/persistence/model/message/message.types.ts b/server/src/persistence/model/message/message.types.ts index 33be7f7..b1d8dd5 100644 --- a/server/src/persistence/model/message/message.types.ts +++ b/server/src/persistence/model/message/message.types.ts @@ -1,5 +1,5 @@ import { Model, Document } from "mongoose"; -import { getDailyMessages } from "./message.statics"; +import { getDailyMessages, getMessagesInTimeRange } from "./message.statics"; export interface IMessage { uuid: string; @@ -11,4 +11,5 @@ export interface IMessage { export interface IMessageDocument extends IMessage, Document {} export interface IMessageModel extends Model { getDailyMessages: typeof getDailyMessages; + getMessagesInTimeRange: typeof getMessagesInTimeRange; } \ No newline at end of file diff --git a/server/src/services/chat.service.ts b/server/src/services/chat.service.ts index c8775c3..e9bbd5f 100644 --- a/server/src/services/chat.service.ts +++ b/server/src/services/chat.service.ts @@ -6,6 +6,14 @@ import { IMessage } from './../persistence/model/message/message.types'; */ class ChatService { + /** + * The number of messages to return in one request. + */ + public static MESSAGE_COUNT_LIMIT = 1000; + + /** + * @deprecated use the endpoint to get messages by time range + */ public async getDailyMessages(): Promise { return (await Message.getDailyMessages()) .map(message => { @@ -18,6 +26,27 @@ class ChatService { }); } + public async getMessagesInTimeRange(from: Date, to: Date): Promise { + if (from >= to) { + throw Error("Please select valid date range"); + } + + return await Message.getMessagesInTimeRange(from, to, ChatService.MESSAGE_COUNT_LIMIT); + } + +} + +/** + * When messages for a specified time range are requested, time-based pagination is required. The metadata for the time-based pagination + * is included in this message, which is returned by the server, along with a subset of all the messages within that range. + */ +export interface ITimeRangeMessages { + requestedFromTimestamp: number; + requestedToTimestamp: number; + returnedFromTimestamp: number; + returnedToTimestamp: number; + messages: IMessage[]; + limit: number; } export default ChatService \ No newline at end of file diff --git a/server/src/services/group.service.ts b/server/src/services/group.service.ts index db34207..ecccc08 100644 --- a/server/src/services/group.service.ts +++ b/server/src/services/group.service.ts @@ -10,12 +10,13 @@ class GroupService { return await Group.getAllGroups(); } - public async saveGroup(group_id: string, provider: string, name: string, size: number): Promise { + public async saveGroup(group_id: string, provider: string, name: string, size: number, number_of_leaves: number): Promise { const group = await Group.create({ group_id: group_id, provider: provider, name: name, - size: size + size: size, + number_of_leaves: number_of_leaves }) return await group.save(); } @@ -24,6 +25,10 @@ class GroupService { return await Group.updateGroupSize(group_id, new_size); } + public async updateNumberOfLeaves(group_id: string, new_number_of_leaves: number): Promise { + return await Group.updateGroupLeafCount(group_id, new_number_of_leaves); + } + public async containsGroup(group_id: string): Promise { const group = await Group.findOne({group_id: group_id}) return group != null ? true : false; diff --git a/server/src/services/message_handler_service.ts b/server/src/services/message_handler_service.ts index 855d456..4211b69 100644 --- a/server/src/services/message_handler_service.ts +++ b/server/src/services/message_handler_service.ts @@ -70,7 +70,7 @@ class MessageHandlerService { const user = getUserFromShares(validMessage.zk_proof, validMessage.x_share, this.hasher, requestStats); // Ban User - await this.userService.removeUser(user.idCommitment, user.secret); + await this.userService.banUser(user.idCommitment, user.secret); // Set user leaf to 0 and recalculate merkle tree await this.userService.updateUser(user.idCommitment); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index ad7ba80..2c07d7e 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -157,6 +157,31 @@ class UserService { return "Done"; } + public async removeUsersByIndexes(indexes: number[], groupId: string): Promise { + if (indexes.length == 0) + return ""; + + // Get the zero hashes. + const zeros = await MerkleTreeZero.findZeros(); + + if (!zeros || zeros.length === 0) { + throw `The zero hashes have not yet been created`; + } + + for (let index of indexes) { + + let node = await MerkleTreeNode.findLeafByGroupIdAndIndexInGroup(groupId, index) + + if (!node) { + throw new Error(`The node with the given index does not exist`) + } + + await this.updateUser(node.hash); + } + + return "Done"; + } + public async updateUser(leafHash: string, newValue: string = config.ZERO_VALUE.toString()) { let node = await MerkleTreeNode.findLeafByHash(leafHash); @@ -183,7 +208,10 @@ class UserService { } - public async removeUser(idCommitment: string, secret: bigint): Promise { + /** + * Bans a user with the given identity commitment. + */ + public async banUser(idCommitment: string, secret: bigint): Promise { const treeNode = await MerkleTreeNode.findLeafByHash(idCommitment); if (!treeNode) { @@ -198,6 +226,7 @@ class UserService { await bannedUser.save(); return idCommitment } + } export default UserService \ No newline at end of file diff --git a/server/tests/interrep/api.test.ts b/server/tests/interrep/api.test.ts index 8b47dfa..11233cd 100644 --- a/server/tests/interrep/api.test.ts +++ b/server/tests/interrep/api.test.ts @@ -62,15 +62,7 @@ describe('Test interrep sync - subgraph', () => { mockAxios.mockResolvedValue({ data: { - data: { - provider: "twitter", - name: "not_sufficient", - depth: 20, - root: "3282736528510229708245753028800701559160032734733920390753117377915762630937", - size: 6, - numberOfLeaves: 6, - members: testMembers_g1 - } + data: testMembers_g1 } }); @@ -81,4 +73,20 @@ describe('Test interrep sync - subgraph', () => { expect(members[5]).toEqual({ index: 5, identityCommitment: 'id-5' }); }); + test('test get deleted members of group - defaults', async () => { + const deletedIndexes = [0, 1, 2] + + mockAxios.mockResolvedValue({ + data: { + data: deletedIndexes + } + }); + + const removedIndexes: number[] = await apiFunctions.getRemovedMembersForGroup("twitter", "not_sufficient"); + expect(removedIndexes.length).toEqual(3); + expect(removedIndexes[0]).toEqual(0); + expect(removedIndexes[1]).toEqual(1); + expect(removedIndexes[2]).toEqual(2); + }); + }); \ No newline at end of file diff --git a/server/tests/interrep/synchronizer.test.ts b/server/tests/interrep/synchronizer.test.ts index c8a0414..5f01ecb 100644 --- a/server/tests/interrep/synchronizer.test.ts +++ b/server/tests/interrep/synchronizer.test.ts @@ -13,21 +13,21 @@ const testGroups: IInterRepGroupV2[] = [ root: "1", provider: "github", name: "GOLD", - size: 6, + size: 4, // 2 deleted members. numberOfLeaves: 6 }, { root: "2", provider: "twitter", name: "GOLD", - size: 10, + size: 9, // 1 deleted member numberOfLeaves: 10 }, { root: "3", provider: "reddit", name: "GOLD", - size: 50, + size: 45, // 5 deleted members numberOfLeaves: 50 } ]; @@ -57,14 +57,34 @@ const getAllGroupsMock = async (): Promise => { return testGroups }; -const getMembersForGroupMock = async (groupid: string): Promise => { - if (groupid == '1') +const getMembersForGroupMock = async (provider: string, name: string): Promise => { + // Init deleted members + testMembers_g1[1].identityCommitment = "0"; + testMembers_g1[2].identityCommitment = "0"; + + testMembers_g2[5].identityCommitment = "0"; + + testMembers_g3[0].identityCommitment = "0"; + testMembers_g3[20].identityCommitment = "0"; + testMembers_g3[21].identityCommitment = "0"; + testMembers_g3[30].identityCommitment = "0"; + testMembers_g3[45].identityCommitment = "0"; + + if (provider == 'github' && name == 'GOLD') return testMembers_g1; - else if (groupid == '2') + else if (provider == 'twitter' && name == 'GOLD') return testMembers_g2; return testMembers_g3; } +const getRemovedMembersForGroupMock = async (provider: string, name: string): Promise => { + if (provider == 'github' && name == 'GOLD') + return [1, 2]; + else if (provider == 'twitter' && name == 'GOLD') + return [5]; + return [0, 20, 21, 30, 45]; +} + describe('Test interrep synchronizer', () => { let testPubSub: PubSub; @@ -80,6 +100,7 @@ describe('Test interrep synchronizer', () => { subgraphFunctions.getAllGroups = getAllGroupsMock; subgraphFunctions.getMembersForGroup = getMembersForGroupMock; + subgraphFunctions.getRemovedMembersForGroup = getRemovedMembersForGroupMock; synchronizer = new InterRepSynchronizer(testPubSub, groupService, userService); }) @@ -99,7 +120,8 @@ describe('Test interrep synchronizer', () => { group_id: 'test', provider: 'test', name: 'test', - size: 1 + size: 1, + number_of_leaves: 1 } }); @@ -119,33 +141,38 @@ describe('Test interrep synchronizer', () => { group_id: "github_GOLD", provider: "github", name: "GOLD", - size: 6 + size: 4, + number_of_leaves: 6 }, { group_id: "twitter_GOLD", provider: "twitter", name: "GOLD", - size: 10 + size: 9, + number_of_leaves: 10 }, { group_id: "reddit_GOLD", provider: "reddit", name: "GOLD", - size: 50 + size: 45, + number_of_leaves: 50 } ]); let saveGroupSpy = jest.spyOn(groupService, 'saveGroup').mockResolvedValue({ group_id: 'test', provider: 'test', name: 'test', - size: 1 + size: 1, + number_of_leaves: 1 }); let updateSizeSpy = jest.spyOn(groupService, 'updateSize').mockResolvedValue({ group_id: 'test', provider: 'test', name: 'test', - size: 1 + size: 1, + number_of_leaves: 1 }); let appendUsersSpy = jest.spyOn(userService, 'appendUsers').mockResolvedValue("success"); @@ -158,20 +185,22 @@ describe('Test interrep synchronizer', () => { expect(appendUsersSpy).not.toHaveBeenCalled(); }); - test('test sync - partial db', async () => { + test('test sync - partial db no deleted records', async () => { let getGroupsSpy = jest.spyOn(groupService, 'getGroups').mockResolvedValue( [ { group_id: "github_GOLD", provider: "github", name: "GOLD", - size: 6 + size: 4, + number_of_leaves: 6 }, { group_id: "twitter_GOLD", provider: "twitter", name: "GOLD", - size: 8 // Less records in db + size: 9, // Same as db + number_of_leaves: 8 // Less records in db } ]); @@ -179,14 +208,16 @@ describe('Test interrep synchronizer', () => { group_id: 'test', provider: 'test', name: 'test', - size: 1 + size: 1, + number_of_leaves: 1 }); - let updateSizeSpy = jest.spyOn(groupService, 'updateSize').mockResolvedValue({ + let updateSizeSpy = jest.spyOn(groupService, 'updateNumberOfLeaves').mockResolvedValue({ group_id: 'test', provider: 'test', name: 'test', - size: 1 + size: 1, + number_of_leaves: 1 }); let appendUsersSpy = jest.spyOn(userService, 'appendUsers').mockResolvedValue("success"); @@ -199,4 +230,57 @@ describe('Test interrep synchronizer', () => { expect(appendUsersSpy).toHaveBeenCalledTimes(2); }); + test('test sync - partial db with deleted records', async () => { + let getGroupsSpy = jest.spyOn(groupService, 'getGroups').mockResolvedValue( + [ + { + group_id: "github_GOLD", + provider: "github", + name: "GOLD", + size: 4, + number_of_leaves: 6 + }, + { + group_id: "twitter_GOLD", + provider: "twitter", + name: "GOLD", + size: 8, // Less records in db, some member was deleted + number_of_leaves: 10 // Same as db + }, + { + group_id: "reddit_GOLD", + provider: "reddit", + name: "GOLD", + size: 45, + number_of_leaves: 50 + } + ]); + + let saveGroupSpy = jest.spyOn(groupService, 'saveGroup').mockResolvedValue({ + group_id: 'test', + provider: 'test', + name: 'test', + size: 1, + number_of_leaves: 1 + }); + + let updateSizeSpy = jest.spyOn(groupService, 'updateSize').mockResolvedValue({ + group_id: 'test', + provider: 'test', + name: 'test', + size: 1, + number_of_leaves: 1 + }); + + let removeUsersByIndexesSpy = jest.spyOn(userService, 'removeUsersByIndexes').mockResolvedValue("success"); + + await synchronizer.syncCommitmentsFromInterRep(); + + expect(saveGroupSpy).not.toHaveBeenCalled(); + expect(getGroupsSpy).toHaveBeenCalled(); + expect(updateSizeSpy).toHaveBeenCalledTimes(1); + expect(removeUsersByIndexesSpy).toHaveBeenCalledTimes(1); + expect(removeUsersByIndexesSpy).toHaveBeenCalledWith([5], 'twitter_GOLD'); + }); + }); \ No newline at end of file diff --git a/server/tests/services/chat.service.test.ts b/server/tests/services/chat.service.test.ts index 9a15e4b..e85939a 100644 --- a/server/tests/services/chat.service.test.ts +++ b/server/tests/services/chat.service.test.ts @@ -1,14 +1,15 @@ import { clearDatabase } from '../jest.setup'; -import { test, expect, describe, afterEach } from '@jest/globals' +import { test, expect, describe, afterEach, beforeEach } from '@jest/globals' import Message from '../../src/persistence/model/message/message.model'; import { IMessage } from '../../src/persistence/model/message/message.types'; -import ChatService from '../../src/services/chat.service' +import ChatService, { ITimeRangeMessages } from '../../src/services/chat.service' import MockDate from 'mockdate'; describe('Test chat service', () => { const timestampTodayMs = 1637837920000; + const msecondsPerDay = 86400000; afterEach(async () => { @@ -48,6 +49,128 @@ describe('Test chat service', () => { expect(data.length).toEqual(1); }); + test('get messages in time range - invalid range', async () => { + jest.setTimeout(30000); + const chatService = new ChatService(); + + try { + await chatService.getMessagesInTimeRange(new Date(timestampTodayMs), new Date(timestampTodayMs - 10)); + expect(false).toBeTruthy(); + } catch (e: any) { + expect(true).toBeTruthy(); + expect(e.message).toEqual("Please select valid date range"); + } + }) + + test('get messages in time range - 1', async () => { + jest.setTimeout(30000); + const chatService = new ChatService(); + + for (let i = 0; i < 100; i++) { + const timestamp = timestampTodayMs + 60000 * i; // 1 minute apart + await insertMessage(i, timestamp); + } + + const lastItemTimestamp = timestampTodayMs + 9 * 60000 + const messageData: ITimeRangeMessages = await chatService.getMessagesInTimeRange( + new Date(timestampTodayMs), + new Date(lastItemTimestamp + 100)); + + expect(messageData.messages.length).toEqual(10); + expect(messageData.returnedFromTimestamp).toEqual(timestampTodayMs); + expect(messageData.returnedToTimestamp).toEqual(lastItemTimestamp); + }) + + test('get messages in time range - 2', async () => { + jest.setTimeout(30000); + const chatService = new ChatService(); + + for (let i = 0; i < 100; i++) { + const timestamp = timestampTodayMs + 60000 * i; // 1 minute apart + await insertMessage(i, timestamp); + } + + const lastItemTimestamp = timestampTodayMs + 9 * 60000 + const messageData: ITimeRangeMessages = await chatService.getMessagesInTimeRange( + new Date(timestampTodayMs + 100), + new Date(lastItemTimestamp + 100)); + + expect(messageData.messages.length).toEqual(10 - 1); // the first one is not returned + expect(messageData.returnedFromTimestamp).toEqual((timestampTodayMs + 60000)); + expect(messageData.returnedToTimestamp).toEqual(lastItemTimestamp); + }) + + test('get messages in time range - pagination single page', async () => { + jest.setTimeout(30000); + const chatService = new ChatService(); + + for (let i = 0; i < ChatService.MESSAGE_COUNT_LIMIT + 100; i++) { + const timestamp = timestampTodayMs + 60000 * i; // 1 minute apart + await insertMessage(i, timestamp); + } + + const numberOfMessagesMoreThanLimit = 10; + const lastItemTimestamp = timestampTodayMs + (ChatService.MESSAGE_COUNT_LIMIT + numberOfMessagesMoreThanLimit) * 60000 + const messageData: ITimeRangeMessages = await chatService.getMessagesInTimeRange( + new Date(timestampTodayMs), + new Date(lastItemTimestamp + 100)); + + expect(messageData.messages.length).toEqual(ChatService.MESSAGE_COUNT_LIMIT); // no more than limit gets returned + + expect(messageData.returnedFromTimestamp).toEqual(timestampTodayMs); + + expect(messageData.returnedToTimestamp).toEqual((lastItemTimestamp - (numberOfMessagesMoreThanLimit + 1) * 60000)); + }) + + test('get messages in time range - pagination single page no items', async () => { + jest.setTimeout(30000); + const chatService = new ChatService(); + + const numberOfMessagesMoreThanLimit = 10; + const lastItemTimestamp = timestampTodayMs + (ChatService.MESSAGE_COUNT_LIMIT + numberOfMessagesMoreThanLimit) * 60000 + const messageData: ITimeRangeMessages = await chatService.getMessagesInTimeRange( + new Date(timestampTodayMs), + new Date(lastItemTimestamp + 100)); + + expect(messageData.messages.length).toEqual(0); + + expect(messageData.returnedFromTimestamp).toEqual(timestampTodayMs); + expect(messageData.returnedToTimestamp).toEqual(lastItemTimestamp + 100); + }) + + test('get messages in time range - pagination all pages', async () => { + jest.setTimeout(30000); + const chatService = new ChatService(); + + for (let i = 0; i < ChatService.MESSAGE_COUNT_LIMIT + 100; i++) { + const timestamp = timestampTodayMs + 60000 * i; // 1 minute apart + await insertMessage(i, timestamp); + } + + let messages: IMessage[] = []; + const numberOfMessagesMoreThanLimit = 10; + const lastItemTimestamp = timestampTodayMs + (ChatService.MESSAGE_COUNT_LIMIT + numberOfMessagesMoreThanLimit) * 60000; + + // Pagination needs to load all until now. + const toTimestamp = new Date(); + let fromTimestamp = new Date(timestampTodayMs); + let messageData: ITimeRangeMessages = await chatService.getMessagesInTimeRange(fromTimestamp, toTimestamp); + messages = messages.concat(messageData.messages); + + while(1) { + if (messageData.messages.length == messageData.limit) { + fromTimestamp = new Date(messageData.returnedToTimestamp + 1) + messageData = await chatService.getMessagesInTimeRange(fromTimestamp, toTimestamp); + messages = messages.concat(messageData.messages); + } else { + break; + } + } + + expect(messages.length).toEqual(ChatService.MESSAGE_COUNT_LIMIT + 100); + }) + + }); const insertMessage = async(id: number, epoch: number) => { diff --git a/server/tests/services/group_service.test.ts b/server/tests/services/group_service.test.ts index fbfb270..c2a8add 100644 --- a/server/tests/services/group_service.test.ts +++ b/server/tests/services/group_service.test.ts @@ -12,7 +12,7 @@ describe('Test group service', () => { test('create group', async () => { const groupService = new GroupService(); - await groupService.saveGroup('id-1', 'github', 'test_1', 10); + await groupService.saveGroup('id-1', 'github', 'test_1', 1, 10); const allGroups = await Group.find({}); expect(allGroups.length).toEqual(1); @@ -21,19 +21,33 @@ describe('Test group service', () => { test('update size', async () => { const groupService = new GroupService(); - await groupService.saveGroup('id-1', 'github', 'test_1', 10); + await groupService.saveGroup('id-1', 'github', 'test_1', 10, 10); await groupService.updateSize('id-1', 15); const allGroups = await Group.find({}); expect(allGroups.length).toEqual(1); expect(allGroups[0].size).toEqual(15); + expect(allGroups[0].number_of_leaves).toEqual(10); + }); + + test('update leaf count', async () => { + const groupService = new GroupService(); + + await groupService.saveGroup('id-1', 'github', 'test_1', 10, 10); + + await groupService.updateNumberOfLeaves('id-1', 15); + + const allGroups = await Group.find({}); + expect(allGroups.length).toEqual(1); + expect(allGroups[0].size).toEqual(10); + expect(allGroups[0].number_of_leaves).toEqual(15); }); test('contains group', async () => { const groupService = new GroupService(); - await groupService.saveGroup('id-1', 'github', 'test_1', 10); + await groupService.saveGroup('id-1', 'github', 'test_1', 1, 10); const containsGroup1 = await groupService.containsGroup("id-1"); expect(containsGroup1).toBeTruthy(); @@ -48,8 +62,8 @@ describe('Test group service', () => { const groups = await groupService.getGroups(); expect(groups.length).toEqual(0); - await groupService.saveGroup('id-1', 'github', 'test_1', 10); - await groupService.saveGroup('id-2', 'github', 'test_2', 10); + await groupService.saveGroup('id-1', 'github', 'test_1',1, 10); + await groupService.saveGroup('id-2', 'github', 'test_2', 1, 10); const groups2 = await groupService.getGroups(); expect(groups2.length).toEqual(2); diff --git a/server/tests/services/message_handle.service.test.ts b/server/tests/services/message_handle.service.test.ts index 70042d4..0c94c1d 100644 --- a/server/tests/services/message_handle.service.test.ts +++ b/server/tests/services/message_handle.service.test.ts @@ -188,7 +188,7 @@ describe('Test message handle service', () => { ]); jest.spyOn(userService, "getRoot").mockResolvedValue(BigInt(123).toString()); jest.spyOn(userService, "updateUser").mockResolvedValue(); - const removeUserSpy = jest.spyOn(userService, "removeUser").mockResolvedValue("success"); + const removeUserSpy = jest.spyOn(userService, "banUser").mockResolvedValue("success"); jest.spyOn(hasher, "verifyProof").mockResolvedValue(true); jest.spyOn(hasher, "retrieveSecret").mockReturnValue(BigInt(100001)); jest.spyOn(hasher, "poseidonHash").mockReturnValue(BigInt(122010101)); diff --git a/server/tests/services/user.service.test.ts b/server/tests/services/user.service.test.ts index ca76a3b..751d40d 100644 --- a/server/tests/services/user.service.test.ts +++ b/server/tests/services/user.service.test.ts @@ -1,11 +1,11 @@ import { clearDatabase } from '../jest.setup'; import { test, expect, describe, afterEach } from '@jest/globals' -import BannedUser from "../../src/persistence/model/banned_user/banned_user.model"; import { IBannedUser } from "../../src/persistence/model/banned_user/banned_user.types"; -import UserService from '../../src/services/user.service'; -import config from "../../src/config" import { MerkleTreeNode, MerkleTreeZero } from '../../src/persistence/model/merkle_tree/merkle_tree.model'; import Hasher from '../../src/util/hasher'; +import UserService from '../../src/services/user.service'; +import config from "../../src/config" +import BannedUser from "../../src/persistence/model/banned_user/banned_user.model"; describe('Test user service', () => { @@ -178,7 +178,7 @@ describe('Test user service', () => { expect(allNodes.length).toEqual(17); // user exists, was not added }); - test('remove user - exists', async () => { + test('ban user - exists', async () => { const user_service = new UserService(); await testSeedZeros(BigInt(0)); @@ -199,16 +199,16 @@ describe('Test user service', () => { expect(result2).toEqual("Done"); - await user_service.removeUser(BigInt(2 ^ 244).toString(), BigInt(13^120)); + await user_service.banUser(BigInt(2 ^ 244).toString(), BigInt(13^120)); const allBannedUsers: IBannedUser[] = await user_service.getAllBannedUsers(); expect(allBannedUsers.length).toEqual(1); }); - test('remove user - not exists', async () => { + test('ban user - not exists', async () => { const user_service = new UserService(); try { - await user_service.removeUser(BigInt(2 ^ 244).toString(), BigInt(11111)); + await user_service.banUser(BigInt(2 ^ 244).toString(), BigInt(11111)); expect(false).toBeTruthy(); } catch(e) { expect(e).toEqual("The user doesn't exists"); @@ -255,6 +255,30 @@ describe('Test user service', () => { } }); + test('remove users by indexes - exists', async () => { + const user_service = new UserService(); + await testSeedZeros(BigInt(0)); + + const hash1 = BigInt(2 ^ 244).toString(); + const hash2 = BigInt(3 ^ 244).toString(); + + await user_service.appendUsers([{ + index: 1, + identityCommitment: hash1 + }], "id-1"); + + await user_service.appendUsers([{ + index: 2, + identityCommitment: hash2 + }], "id-1"); + + await user_service.removeUsersByIndexes([1], 'id-1'); + + let deletedUser = await MerkleTreeNode.findByLevelAndIndex(0, 1); + expect(deletedUser).not.toBeNull(); + expect(deletedUser!.hash).not.toEqual(hash1); + }); + }); const insertBannedUser = async (id: number) => { diff --git a/yarn.lock b/yarn.lock index 345e403..1976bc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3957,6 +3957,11 @@ base-x@^3.0.2, base-x@^3.0.8: dependencies: safe-buffer "^5.0.1" +base64-arraybuffer-es6@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz#dbe1e6c87b1bf1ca2875904461a7de40f21abc86" + integrity sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw== + base64-js@^1.0.2, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -5093,6 +5098,11 @@ core-js@^2.4.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== +core-js@^3.4: + version "3.21.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.21.1.tgz#f2e0ddc1fc43da6f904706e8e955bc19d06a0d94" + integrity sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig== + core-js@^3.6.5: version "3.19.3" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.19.3.tgz#6df8142a996337503019ff3235a7022d7cdf4559" @@ -5718,6 +5728,11 @@ detect-port-alt@1.1.6: address "^1.0.1" debug "^2.6.0" +dexie@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.2.1.tgz#ef21456d725e700c1ab7ac4307896e4fdabaf753" + integrity sha512-Y8oz3t2XC9hvjkP35B5I8rUkKKwM36GGRjWQCMjzIYScg7W+GHKDXobSYswkisW7CxL1/tKQtggMDsiWqDUc1g== + diff-sequences@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" @@ -5845,6 +5860,13 @@ domelementtype@^2.0.1, domelementtype@^2.2.0: resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== +domexception@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" + integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug== + dependencies: + webidl-conversions "^4.0.2" + domexception@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" @@ -6733,6 +6755,13 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07" integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA== +fake-indexeddb@^3.1.7: + version "3.1.7" + resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-3.1.7.tgz#d9efbeade113c15efbe862e4598a4b0a1797ed9f" + integrity sha512-CUGeCzCOVjmeKi2C0pcvSh6NDU6uQIaS+7YyR++tO/atJJujkBYVhDvfePdz/U8bD33BMVWirsr1MKczfAqbjA== + dependencies: + realistic-structured-clone "^2.0.1" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -12202,6 +12231,11 @@ prepend-http@^2.0.0: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= +prettier@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032" + integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew== + pretty-bytes@^5.3.0: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" @@ -12793,6 +12827,16 @@ readline@^1.3.0: resolved "https://registry.yarnpkg.com/readline/-/readline-1.3.0.tgz#c580d77ef2cfc8752b132498060dc9793a7ac01c" integrity sha1-xYDXfvLPyHUrEySYBg3JeTp6wBw= +realistic-structured-clone@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-2.0.4.tgz#7eb4c2319fc3cb72f4c8d3c9e888b11647894b50" + integrity sha512-lItAdBIFHUSe6fgztHPtmmWqKUgs+qhcYLi3wTRUl4OTB3Vb8aBVSjGfQZUvkmJCKoX3K9Wf7kyLp/F/208+7A== + dependencies: + core-js "^3.4" + domexception "^1.0.1" + typeson "^6.1.0" + typeson-registry "^1.0.0-alpha.20" + recursive-readdir@2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.2.tgz#9946fb3274e1628de6e36b2f6714953b4845094f" @@ -14734,6 +14778,20 @@ typescript@^4.1.2, typescript@^4.5.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.2.tgz#8ac1fba9f52256fdb06fb89e4122fa6a346c2998" integrity sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw== +typeson-registry@^1.0.0-alpha.20: + version "1.0.0-alpha.39" + resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" + integrity sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw== + dependencies: + base64-arraybuffer-es6 "^0.7.0" + typeson "^6.0.0" + whatwg-url "^8.4.0" + +typeson@^6.0.0, typeson@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b" + integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA== + uWebSockets.js@uNetworking/uWebSockets.js#v20.4.0: version "20.4.0" resolved "https://codeload.github.com/uNetworking/uWebSockets.js/tar.gz/65f39bdff763be3883e6cf18e433dd4fec155845" @@ -15427,6 +15485,11 @@ webcrypto-core@^1.4.0: pvtsutils "^1.2.0" tslib "^2.3.1" +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== + webidl-conversions@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" @@ -15598,7 +15661,7 @@ whatwg-url@^11.0.0: tr46 "^3.0.0" webidl-conversions "^7.0.0" -whatwg-url@^8.0.0, whatwg-url@^8.5.0: +whatwg-url@^8.0.0, whatwg-url@^8.4.0, whatwg-url@^8.5.0: version "8.7.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77" integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==