From 80c311d853c77e433c4bad67b466d7bf31206d43 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Tue, 5 Jan 2021 12:44:52 +0000 Subject: [PATCH] Integrate Redux into front end --- package.json | 5 + src/open_chat_assets/public/App.tsx | 27 +-- .../public/actions/chats/getAllChats.ts | 45 +++++ .../public/actions/chats/selectChat.ts | 13 ++ .../public/actions/chats/sendMessage.ts | 144 ++++++++++++++ .../actions/chats/setupNewDirectChat.ts | 103 ++++++++++ .../public/actions/users/getCurrentUser.ts | 45 +++++ .../public/actions/users/getUsers.ts | 46 +++++ .../public/actions/users/registerUser.ts | 64 +++++++ .../public/backgroundTasks.ts | 53 ++++++ .../public/components/ChatSelection.tsx | 19 ++ .../public/components/Main.tsx | 5 + .../public/components/Side.tsx | 33 ++++ .../public/components/SideHeader.tsx | 16 ++ src/open_chat_assets/public/index.tsx | 12 +- src/open_chat_assets/public/model/chats.ts | 2 + src/open_chat_assets/public/model/messages.ts | 7 +- .../public/reducers/chatsReducer.ts | 158 ++++++++++++++++ src/open_chat_assets/public/reducers/index.ts | 13 ++ .../public/reducers/usersReducer.ts | 179 ++++++++++++++++++ .../public/services/chats/listChats.ts | 10 +- .../services/userMgmt/getCurrentUser.ts | 4 +- .../public/services/userMgmt/getUserId.ts | 2 +- .../public/services/userMgmt/getUsers.ts | 2 +- .../public/services/userMgmt/registerUser.ts | 2 +- .../services/userMgmt/updateUsername.ts | 2 +- src/open_chat_assets/public/store.ts | 20 ++ src/open_chat_assets/public/utils.ts | 12 ++ 28 files changed, 1019 insertions(+), 24 deletions(-) create mode 100644 src/open_chat_assets/public/actions/chats/getAllChats.ts create mode 100644 src/open_chat_assets/public/actions/chats/selectChat.ts create mode 100644 src/open_chat_assets/public/actions/chats/sendMessage.ts create mode 100644 src/open_chat_assets/public/actions/chats/setupNewDirectChat.ts create mode 100644 src/open_chat_assets/public/actions/users/getCurrentUser.ts create mode 100644 src/open_chat_assets/public/actions/users/getUsers.ts create mode 100644 src/open_chat_assets/public/actions/users/registerUser.ts create mode 100644 src/open_chat_assets/public/backgroundTasks.ts create mode 100644 src/open_chat_assets/public/components/ChatSelection.tsx create mode 100644 src/open_chat_assets/public/components/Main.tsx create mode 100644 src/open_chat_assets/public/components/Side.tsx create mode 100644 src/open_chat_assets/public/components/SideHeader.tsx create mode 100644 src/open_chat_assets/public/reducers/chatsReducer.ts create mode 100644 src/open_chat_assets/public/reducers/index.ts create mode 100644 src/open_chat_assets/public/reducers/usersReducer.ts create mode 100644 src/open_chat_assets/public/store.ts create mode 100644 src/open_chat_assets/public/utils.ts diff --git a/package.json b/package.json index e5b7db9069..f71e06eabb 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,16 @@ }, "dependencies": { "@dfinity/agent": "0.6.14", + "@reduxjs/toolkit": "1.5.0", "@types/react": "17.0.0", "@types/react-dom": "17.0.0", + "@types/react-redux": "^7.1.15", "css-loader": "5.0.1", "react": "17.0.1", "react-dom": "17.0.1", + "react-redux": "^7.2.2", + "redux-devtools-extension": "^2.13.8", + "redux-thunk": "^2.3.0", "terser-webpack-plugin": "2.2.2", "ts-loader": "8.0.12", "typescript": "4.1.3", diff --git a/src/open_chat_assets/public/App.tsx b/src/open_chat_assets/public/App.tsx index c7d40f24f4..80fc08ecc2 100644 --- a/src/open_chat_assets/public/App.tsx +++ b/src/open_chat_assets/public/App.tsx @@ -1,16 +1,21 @@ -import React, { Component } from "react"; +import React from "react"; + +import { setupBackgroundTasks } from "./backgroundTasks"; + +import Main from "./components/Main"; +import Side from "./components/Side"; + +export default function() { + setupBackgroundTasks(); -class App extends Component { - render() { return ( -
-
- Hello! +
+
+ +
+
+
+
-
); - } } - -export default App; - diff --git a/src/open_chat_assets/public/actions/chats/getAllChats.ts b/src/open_chat_assets/public/actions/chats/getAllChats.ts new file mode 100644 index 0000000000..391c82418e --- /dev/null +++ b/src/open_chat_assets/public/actions/chats/getAllChats.ts @@ -0,0 +1,45 @@ +import chatService from "../../services/chats/service"; +import { Chat } from "../../model/chats"; + +export const GET_ALL_CHATS_REQUESTED = "GET_ALL_CHATS_REQUESTED"; +export const GET_ALL_CHATS_SUCCEEDED = "GET_ALL_CHATS_SUCCEEDED"; +export const GET_ALL_CHATS_FAILED = "GET_ALL_CHATS_FAILED"; + +export default function() { + return async (dispatch: any) => { + const requestEvent: GetAllChatsRequestedEvent = { + type: GET_ALL_CHATS_REQUESTED + }; + + dispatch(requestEvent); + + const result = await chatService.listChats(false); + + let outcomeEvent; + if (result.kind === "success") { + outcomeEvent = { + type: GET_ALL_CHATS_SUCCEEDED, + payload: result.chats + } as GetAllChatsSucceededEvent; + } else { + outcomeEvent = { + type: GET_ALL_CHATS_FAILED + } as GetAllChatsFailedEvent; + } + + dispatch(outcomeEvent); + } +} + +export type GetAllChatsRequestedEvent = { + type: typeof GET_ALL_CHATS_REQUESTED +} + +export type GetAllChatsSucceededEvent = { + type: typeof GET_ALL_CHATS_SUCCEEDED, + payload: Chat[] +} + +export type GetAllChatsFailedEvent = { + type: typeof GET_ALL_CHATS_FAILED +} diff --git a/src/open_chat_assets/public/actions/chats/selectChat.ts b/src/open_chat_assets/public/actions/chats/selectChat.ts new file mode 100644 index 0000000000..0f97a740da --- /dev/null +++ b/src/open_chat_assets/public/actions/chats/selectChat.ts @@ -0,0 +1,13 @@ +export const CHAT_SELECTED = "CHAT_SELECTED"; + +export default function(index: number) { + return { + type: CHAT_SELECTED, + payload: index + }; +} + +export type ChatSelectedEvent = { + type: typeof CHAT_SELECTED, + payload: number +} diff --git a/src/open_chat_assets/public/actions/chats/sendMessage.ts b/src/open_chat_assets/public/actions/chats/sendMessage.ts new file mode 100644 index 0000000000..912858883a --- /dev/null +++ b/src/open_chat_assets/public/actions/chats/sendMessage.ts @@ -0,0 +1,144 @@ +import chatService from "../../services/chats/service"; +import { ChatId } from "../../model/chats"; +import { Option, Timestamp } from "../../model/common"; +import { UserId } from "../../model/users"; + +export const SEND_MESSAGE_REQUESTED = "SEND_MESSAGE_REQUESTED"; +export const SEND_MESSAGE_SUCCEEDED = "SEND_MESSAGE_SUCCEEDED"; +export const SEND_MESSAGE_FAILED = "SEND_MESSAGE_FAILED"; + +export default function(userId: UserId, chatId: Option, message: string) { + return async (dispatch: any) => { + const id = Symbol("id"); + + const requestEvent: SendMessageRequestedEvent = { + type: SEND_MESSAGE_REQUESTED, + payload: { + kind: "direct", + userId: userId, + chatId: chatId, + message: message, + unconfirmedMessageId: id + } + }; + + dispatch(requestEvent); + + const response = chatId + ? await chatService.sendMessage(chatId, message) + : await chatService.sendDirectMessage(userId, message); + + let outcomeEvent; + if (response.kind === "success") { + outcomeEvent = { + type: SEND_MESSAGE_SUCCEEDED, + payload: { + kind: "direct", + userId: userId, + chatId: chatId, + message: message, + unconfirmedMessageId: id, + confirmedMessageId: response.result.messageId, + confirmedMessageTimestamp: response.result.timestamp + } + } as SendMessageSucceededEvent; + } else { + outcomeEvent = { + type: SEND_MESSAGE_FAILED + } as SendMessageFailedEvent; + } + + dispatch(outcomeEvent); + } +} + +export const sendGroupMessage = (chatId: ChatId, message: string) => async (dispatch: any) => { + const id = Symbol("id"); + + const requestEvent: SendMessageRequestedEvent = { + type: SEND_MESSAGE_REQUESTED, + payload: { + kind: "group", + chatId: chatId, + message: message, + unconfirmedMessageId: id + } + }; + + dispatch(requestEvent); + + const response = await chatService.sendMessage(chatId, message); + + let outcomeEvent; + if (response.kind === "success") { + outcomeEvent = { + type: SEND_MESSAGE_SUCCEEDED, + payload: { + kind: "group", + chatId: chatId, + message: message, + unconfirmedMessageId: id, + confirmedMessageId: response.result.messageId, + confirmedMessageTimestamp: response.result.timestamp + } + } as SendMessageSucceededEvent; + } else { + outcomeEvent = { + type: SEND_MESSAGE_FAILED + } as SendMessageFailedEvent; + } + + dispatch(outcomeEvent); +} + +export type SendMessageRequestedEvent = { + type: typeof SEND_MESSAGE_REQUESTED, + payload: SendMessageRequest +} + +export type SendMessageSucceededEvent = { + type: typeof SEND_MESSAGE_SUCCEEDED, + payload: SendMessageSuccess +} + +export type SendMessageFailedEvent = { + type: typeof SEND_MESSAGE_FAILED +} + +export type SendMessageRequest = SendDirectMessageRequest | SendGroupMessageRequest; + +export type SendDirectMessageRequest = { + kind: "direct", + userId: UserId, + chatId: Option, + message: string, + unconfirmedMessageId: Symbol +} + +export type SendGroupMessageRequest = { + kind: "group", + chatId: ChatId, + message: string, + unconfirmedMessageId: Symbol +} + +export type SendMessageSuccess = SendDirectMessageSuccess | SendGroupMessageSuccess; + +export type SendDirectMessageSuccess = { + kind: "direct", + userId: UserId, + chatId: Option, + message: string, + unconfirmedMessageId: Symbol, + confirmedMessageId: number, + confirmedMessageTimestamp: Timestamp +} + +export type SendGroupMessageSuccess = { + kind: "group", + chatId: ChatId, + message: string, + unconfirmedMessageId: Symbol, + confirmedMessageId: number, + confirmedMessageTimestamp: Timestamp +} diff --git a/src/open_chat_assets/public/actions/chats/setupNewDirectChat.ts b/src/open_chat_assets/public/actions/chats/setupNewDirectChat.ts new file mode 100644 index 0000000000..c1968f2a96 --- /dev/null +++ b/src/open_chat_assets/public/actions/chats/setupNewDirectChat.ts @@ -0,0 +1,103 @@ +import getUserId from "../../services/userMgmt/getUserId"; +import { RootState } from "../../reducers"; +import { Chat } from "../../model/chats"; +import { Option } from "../../model/common"; +import { UserId, UserSummary } from "../../model/users"; +import { userIdsEqual } from "../../utils"; + +export const SETUP_NEW_DIRECT_CHAT_REQUESTED = "SETUP_NEW_DIRECT_CHAT_REQUESTED"; +export const SETUP_NEW_DIRECT_CHAT_SUCCEEDED = "SETUP_NEW_DIRECT_CHAT_SUCCEEDED"; +export const SETUP_NEW_DIRECT_CHAT_FAILED_USER_NOT_FOUND = "SETUP_NEW_DIRECT_CHAT_FAILED_USER_NOT_FOUND"; +export const SETUP_NEW_DIRECT_CHAT_FAILED_CHAT_ALREADY_EXISTS = "SETUP_NEW_DIRECT_CHAT_FAILED_CHAT_ALREADY_EXISTS"; +export const SETUP_NEW_DIRECT_CHAT_FAILED_CANT_CREATE_CHAT_WITH_SELF = "SETUP_NEW_DIRECT_CHAT_FAILED_CANT_CREATE_CHAT_WITH_SELF"; + +export default function(username: string) { + return async (dispatch: any, getState: () => RootState) => { + const requestEvent: SetupNewDirectChatRequestedEvent = { + type: SETUP_NEW_DIRECT_CHAT_REQUESTED, + payload: username + }; + + dispatch(requestEvent); + + const outcomeEvent = await getOutcomeEvent(); + + dispatch(outcomeEvent); + + async function getOutcomeEvent() { + const state = getState(); + + if (state.usersState.me!.username === username) { + return { + type: SETUP_NEW_DIRECT_CHAT_FAILED_CANT_CREATE_CHAT_WITH_SELF + } as SetupNewDirectChatFailedCantCreateChatWithSelfEvent; + } + + let userId = findUserId(state.usersState.userDictionary, username); + if (!userId) { + const getUserResponse = await getUserId(username); + + if (getUserResponse.kind === "success") { + userId = getUserResponse.userId; + } else { + return { + type: SETUP_NEW_DIRECT_CHAT_FAILED_USER_NOT_FOUND, + payload: username + } as SetupNewDirectChatFailedUserNotFoundEvent; + } + } + + if (!chatAlreadyExists(state.chatsState.chats, userId)) { + return { + type: SETUP_NEW_DIRECT_CHAT_SUCCEEDED, + payload: { + userId, + username, + version: 0 + } + } as SetupNewDirectChatSucceededEvent; + } else { + return { + type: SETUP_NEW_DIRECT_CHAT_FAILED_CHAT_ALREADY_EXISTS, + payload: username + } as SetupNewDirectChatFailedChatAlreadyExistsEvent; + } + } + } +} + +function chatAlreadyExists(chats: Chat[], userId: UserId) : boolean { + const chat = chats.find(c => c.kind === "direct" && userIdsEqual(c.them, userId)); + + return Boolean(chat); +} + +function findUserId(userDictionary: any, username: string) : Option { + const key = Object.keys(userDictionary).find(k => userDictionary[k].username === username); + + return key ? userDictionary[key].userId : null; +} + +export type SetupNewDirectChatRequestedEvent = { + type: typeof SETUP_NEW_DIRECT_CHAT_REQUESTED, + payload: string +} + +export type SetupNewDirectChatSucceededEvent = { + type: typeof SETUP_NEW_DIRECT_CHAT_SUCCEEDED, + payload: UserSummary +} + +export type SetupNewDirectChatFailedUserNotFoundEvent = { + type: typeof SETUP_NEW_DIRECT_CHAT_FAILED_USER_NOT_FOUND, + payload: string +} + +export type SetupNewDirectChatFailedChatAlreadyExistsEvent = { + type: typeof SETUP_NEW_DIRECT_CHAT_FAILED_CHAT_ALREADY_EXISTS, + payload: string +} + +export type SetupNewDirectChatFailedCantCreateChatWithSelfEvent = { + type: typeof SETUP_NEW_DIRECT_CHAT_FAILED_CANT_CREATE_CHAT_WITH_SELF +} diff --git a/src/open_chat_assets/public/actions/users/getCurrentUser.ts b/src/open_chat_assets/public/actions/users/getCurrentUser.ts new file mode 100644 index 0000000000..2c335c0ea6 --- /dev/null +++ b/src/open_chat_assets/public/actions/users/getCurrentUser.ts @@ -0,0 +1,45 @@ +import { UserSummary } from "../../model/users"; +import userMgmtService from "../../services/userMgmt/service"; + +export const GET_CURRENT_USER_REQUESTED = "GET_CURRENT_USER_REQUESTED"; +export const GET_CURRENT_USER_SUCCEEDED = "GET_CURRENT_USER_SUCCEEDED"; +export const GET_CURRENT_USER_FAILED = "GET_CURRENT_USER_FAILED"; + +export default function() { + return async (dispatch: any) => { + const requestEvent: GetCurrentUserRequestedEvent = { + type: GET_CURRENT_USER_REQUESTED + }; + + dispatch(requestEvent); + + const result = await userMgmtService.getCurrentUser(); + + let outcomeEvent; + if (result.kind === "success") { + outcomeEvent = { + type: GET_CURRENT_USER_SUCCEEDED, + payload: result.userSummary + } as GetCurrentUserSucceededEvent; + } else { + outcomeEvent = { + type: GET_CURRENT_USER_FAILED + } as GetCurrentUserFailedEvent; + } + + dispatch(outcomeEvent); + } +} + +export type GetCurrentUserRequestedEvent = { + type: typeof GET_CURRENT_USER_REQUESTED +} + +export type GetCurrentUserSucceededEvent = { + type: typeof GET_CURRENT_USER_SUCCEEDED, + payload: UserSummary +} + +export type GetCurrentUserFailedEvent = { + type: typeof GET_CURRENT_USER_FAILED +} diff --git a/src/open_chat_assets/public/actions/users/getUsers.ts b/src/open_chat_assets/public/actions/users/getUsers.ts new file mode 100644 index 0000000000..da2da2e90a --- /dev/null +++ b/src/open_chat_assets/public/actions/users/getUsers.ts @@ -0,0 +1,46 @@ +import { UserSummary } from "../../model/users"; +import { GetUserRequest } from "../../services/userMgmt/getUsers"; +import userMgmtService from "../../services/userMgmt/service"; + +export const GET_USERS_REQUESTED = "GET_USERS_REQUESTED"; +export const GET_USERS_SUCCEEDED = "GET_USERS_SUCCEEDED"; +export const GET_USERS_FAILED = "GET_USERS_FAILED"; + +export default function(users: GetUserRequest[]) { + return async (dispatch: any) => { + const requestAction: GetUsersRequestedEvent = { + type: GET_USERS_REQUESTED + }; + + dispatch(requestAction); + + const result = await userMgmtService.getUsers(users); + + let outcomeEvent; + if (result.kind === "success") { + outcomeEvent = { + type: GET_USERS_SUCCEEDED, + payload: result.users + } as GetUsersSucceededEvent; + } else { + outcomeEvent = { + type: GET_USERS_FAILED, + } as GetUsersFailedEvent; + } + + dispatch(outcomeEvent); + } +} + +export type GetUsersRequestedEvent = { + type: typeof GET_USERS_REQUESTED +} + +export type GetUsersSucceededEvent = { + type: typeof GET_USERS_SUCCEEDED, + payload: UserSummary[] +} + +export type GetUsersFailedEvent = { + type: typeof GET_USERS_FAILED +} diff --git a/src/open_chat_assets/public/actions/users/registerUser.ts b/src/open_chat_assets/public/actions/users/registerUser.ts new file mode 100644 index 0000000000..716efe914c --- /dev/null +++ b/src/open_chat_assets/public/actions/users/registerUser.ts @@ -0,0 +1,64 @@ +import userMgmtService from "../../services/userMgmt/service"; +import { UserSummary } from "../../model/users"; + +export const REGISTER_USER_REQUESTED = "REGISTER_USER_REQUESTED"; +export const REGISTER_USER_SUCCEEDED = "REGISTER_USER_SUCCEEDED"; +export const REGISTER_USER_FAILED_USER_EXISTS = "REGISTER_USER_FAILED_USER_EXISTS"; +export const REGISTER_USER_FAILED_USERNAME_EXISTS = "REGISTER_USER_FAILED_USERNAME_EXISTS"; + +export default function(username: string) { + return async (dispatch: any) => { + const requestEvent: RegisterUserRequestedEvent = { + type: REGISTER_USER_REQUESTED, + payload: username + }; + + dispatch(requestEvent); + + const result = await userMgmtService.registerUser(username); + + let outcomeEvent; + switch (result.kind) { + case "success": + outcomeEvent = { + type: REGISTER_USER_SUCCEEDED, + payload: result.userSummary + } as RegisterUserSucceededEvent; + break; + + case "userExists": + outcomeEvent = { + type: REGISTER_USER_FAILED_USER_EXISTS + } as RegisterUserFailedUserExistsEvent; + break; + + case "usernameTaken": + outcomeEvent = { + type: REGISTER_USER_FAILED_USERNAME_EXISTS, + payload: username + } as RegisterUserFailedUsernameExistsEvent; + break; + } + + dispatch(outcomeEvent); + } +} + +export type RegisterUserRequestedEvent = { + type: typeof REGISTER_USER_REQUESTED, + payload: string +} + +export type RegisterUserSucceededEvent = { + type: typeof REGISTER_USER_SUCCEEDED, + payload: UserSummary +} + +export type RegisterUserFailedUserExistsEvent = { + type: typeof REGISTER_USER_FAILED_USER_EXISTS +} + +export type RegisterUserFailedUsernameExistsEvent = { + type: typeof REGISTER_USER_FAILED_USERNAME_EXISTS, + payload: string +} diff --git a/src/open_chat_assets/public/backgroundTasks.ts b/src/open_chat_assets/public/backgroundTasks.ts new file mode 100644 index 0000000000..9377512ac4 --- /dev/null +++ b/src/open_chat_assets/public/backgroundTasks.ts @@ -0,0 +1,53 @@ +import { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { UserId } from "./model/users"; +import { RootState } from "./reducers"; +import { GetUserRequest } from "./services/userMgmt/getUsers"; + +import getAllChats from "./actions/chats/getAllChats"; +import getCurrentUser from "./actions/users/getCurrentUser"; +import getUsers from "./actions/users/getUsers"; +import registerUser from "./actions/users/registerUser"; + +export function setupBackgroundTasks() { + const dispatch = useDispatch(); + + const userState = useSelector((state: RootState) => state.usersState); + + // If 'userState.mustRegisterAsNewUser' is false, attempt to get details of the current user if not already known + useEffect(() => { + if (!userState.mustRegisterAsNewUser && !userState.me) { + dispatch(getCurrentUser()); + } + }, [userState.mustRegisterAsNewUser]); + + // If 'userState.mustRegisterAsNewUser' is true then prompt the user to register + useEffect(() => { + if (userState.mustRegisterAsNewUser) { + const username = window.prompt("Enter username:"); + + if (username) { + dispatch(registerUser(username)); + } + } + }); + + // Each time 'userState.me' changes and is not null, get the full list of chats + useEffect(() => { + if (userState.me) { + dispatch(getAllChats()); + } + }, [userState.me]); + + // As new userIds are seen, fetch their usernames + useEffect(() => { + if (userState.unknownUserIds.length) { + const users: GetUserRequest[] = userState + .unknownUserIds + .map((u: UserId) => ({ userId: u, cachedVersion: null })); + + dispatch(getUsers(users)); + } + }, [userState.unknownUserIds]); +}; diff --git a/src/open_chat_assets/public/components/ChatSelection.tsx b/src/open_chat_assets/public/components/ChatSelection.tsx new file mode 100644 index 0000000000..535d53fe77 --- /dev/null +++ b/src/open_chat_assets/public/components/ChatSelection.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { useDispatch } from "react-redux"; + +import selectChat from "../actions/chats/selectChat"; + +type Props = { + name: string, + index: number +} + +export default function(props: Props) { + const dispatch = useDispatch(); + + return ( +
  • dispatch(selectChat(props.index))}> + {props.name} +
  • + ); +} diff --git a/src/open_chat_assets/public/components/Main.tsx b/src/open_chat_assets/public/components/Main.tsx new file mode 100644 index 0000000000..f326decf3c --- /dev/null +++ b/src/open_chat_assets/public/components/Main.tsx @@ -0,0 +1,5 @@ +import React from "react"; + +export default function() { + return
    ; +} diff --git a/src/open_chat_assets/public/components/Side.tsx b/src/open_chat_assets/public/components/Side.tsx new file mode 100644 index 0000000000..b75197dd66 --- /dev/null +++ b/src/open_chat_assets/public/components/Side.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import { RootState } from "../reducers"; + +import ChatSelection from "./ChatSelection"; +import SideHeader from "./SideHeader"; + +export default function() { + const chatsState = useSelector((state: RootState) => state.chatsState); + const userDictionary: any = useSelector((state: RootState) => state.usersState.userDictionary); + + const chats = chatsState.chats.map((c, index) => { + let name: string; + if (c.kind === "direct") { + name = "Direct: " + (userDictionary.hasOwnProperty(c.them) ? userDictionary[c.them].username : ""); + } else { + name = "Group: " + c.subject; + } + + return ( + + ); + }); + + return ( +
    + +
      + {chats} +
    +
    + ); +} diff --git a/src/open_chat_assets/public/components/SideHeader.tsx b/src/open_chat_assets/public/components/SideHeader.tsx new file mode 100644 index 0000000000..a9f5c38872 --- /dev/null +++ b/src/open_chat_assets/public/components/SideHeader.tsx @@ -0,0 +1,16 @@ +import React, { useState } from "react"; +import { useDispatch } from "react-redux"; + +import setupNewDirectChat from "../actions/chats/setupNewDirectChat"; + +export default function() { + const [newChatUsername, setNewChatUsername] = useState(""); + const dispatch = useDispatch(); + + return ( +
    + setNewChatUsername(e.target.value)}/> + +
    + ); +} diff --git a/src/open_chat_assets/public/index.tsx b/src/open_chat_assets/public/index.tsx index 7ca79b6932..074b39070a 100644 --- a/src/open_chat_assets/public/index.tsx +++ b/src/open_chat_assets/public/index.tsx @@ -1,6 +1,14 @@ import React from "react"; import ReactDOM from "react-dom"; -import "./index.css"; +import { Provider } from "react-redux"; + import App from "./App"; +import store from "./store"; +import "./index.css"; -ReactDOM.render(, document.getElementById("app")); +ReactDOM.render( + + + , + document.getElementById("app") +); diff --git a/src/open_chat_assets/public/model/chats.ts b/src/open_chat_assets/public/model/chats.ts index 3ec7e5a9d3..9e4e33704f 100644 --- a/src/open_chat_assets/public/model/chats.ts +++ b/src/open_chat_assets/public/model/chats.ts @@ -12,6 +12,7 @@ export type DirectChat = { updatedDate: Timestamp, latestMessageId: number, readUpTo: number, + missingMessages: number[], messages: Message[] } @@ -23,5 +24,6 @@ export type GroupChat = { participants: UserId[], latestMessageId: number, readUpTo: number, + missingMessages: number[], messages: Message[]; } diff --git a/src/open_chat_assets/public/model/messages.ts b/src/open_chat_assets/public/model/messages.ts index 9754917152..86c13ca049 100644 --- a/src/open_chat_assets/public/model/messages.ts +++ b/src/open_chat_assets/public/model/messages.ts @@ -1,7 +1,7 @@ import { Timestamp } from "./common"; import { UserId } from "./users"; -export type Message = ConfirmedMessage | UnconfirmedMessage; +export type Message = ConfirmedMessage | UnconfirmedMessage | MissingMessage; export type ConfirmedMessage = { kind: "confirmed", @@ -16,3 +16,8 @@ export type UnconfirmedMessage = { id: Symbol, text: string } + +export type MissingMessage = { + kind: "missing", + id: number +} diff --git a/src/open_chat_assets/public/reducers/chatsReducer.ts b/src/open_chat_assets/public/reducers/chatsReducer.ts new file mode 100644 index 0000000000..2801f6f71f --- /dev/null +++ b/src/open_chat_assets/public/reducers/chatsReducer.ts @@ -0,0 +1,158 @@ +import { Chat, ChatId, DirectChat } from "../model/chats"; +import { Option } from "../model/common"; +import { ConfirmedMessage, Message, UnconfirmedMessage } from "../model/messages"; +import { UserId } from "../model/users"; +import { chatIdsEqual, userIdsEqual } from "../utils"; + +import { CHAT_SELECTED, ChatSelectedEvent } from "../actions/chats/selectChat"; +import { SETUP_NEW_DIRECT_CHAT_SUCCEEDED, SetupNewDirectChatSucceededEvent } from "../actions/chats/setupNewDirectChat"; + +import { + GET_ALL_CHATS_SUCCEEDED, + GetAllChatsFailedEvent, + GetAllChatsRequestedEvent, + GetAllChatsSucceededEvent +} from "../actions/chats/getAllChats"; + + +import { + SEND_MESSAGE_REQUESTED, + SEND_MESSAGE_SUCCEEDED, + SendMessageFailedEvent, + SendMessageRequestedEvent, + SendMessageSucceededEvent +} from "../actions/chats/sendMessage"; + +type State = { + chats: Chat[], + selectedChatIndex: Option +} + +const initialState: State = { + chats: [], + selectedChatIndex: null +}; + +type Event = + ChatSelectedEvent | + GetAllChatsRequestedEvent | + GetAllChatsSucceededEvent | + GetAllChatsFailedEvent | + SendMessageRequestedEvent | + SendMessageSucceededEvent | + SendMessageFailedEvent | + SetupNewDirectChatSucceededEvent; + +export default function(state: State = initialState, event: Event) : State { + switch (event.type) { + case CHAT_SELECTED: { + return { + ...state, + selectedChatIndex: event.payload + } + } + + case GET_ALL_CHATS_SUCCEEDED: { + return { + ...state, + chats: event.payload, + selectedChatIndex: event.payload.length ? 0 : null + }; + } + + case SEND_MESSAGE_REQUESTED: { + const payload = event.payload; + const chatsCopy = state.chats.slice(); + const chatIndex = payload.kind === "direct" + ? findDirectChatIndex(chatsCopy, payload.userId) + : findGroupChatIndex(chatsCopy, payload.chatId); + + const chatCopy = { ...chatsCopy[chatIndex] }; + const unconfirmedMessage : UnconfirmedMessage = { + kind: "unconfirmed", + id: payload.unconfirmedMessageId, + text: payload.message + }; + chatCopy.messages.push(unconfirmedMessage); + + chatsCopy.splice(chatIndex, 1); + + return { + chats: [chatCopy, ...chatsCopy], + selectedChatIndex: 0 + }; + } + + case SEND_MESSAGE_SUCCEEDED: { + const payload = event.payload; + const chatsCopy = state.chats.slice(); + const chatIndex = payload.kind === "direct" + ? findDirectChatIndex(chatsCopy, payload.userId) + : findGroupChatIndex(chatsCopy, payload.chatId); + + const chatCopy = { ...chatsCopy[chatIndex] }; + const messageIndex = chatCopy.messages.findIndex(m => m.kind === "unconfirmed" && m.id === payload.unconfirmedMessageId); + const confirmedMessage: ConfirmedMessage = { + kind: "confirmed", + id: payload.confirmedMessageId, + timestamp: payload.confirmedMessageTimestamp, + sender: 0, // TODO Get the actual value + text: payload.message + } + chatCopy.messages[messageIndex] = confirmedMessage; + chatCopy.messages.sort(sortMessages); + + if (chatCopy.latestMessageId < confirmedMessage.id) { + chatCopy.latestMessageId = confirmedMessage.id; + } + + // TODO Check for any missing messages / duplicates + + return { + chats: chatsCopy, + selectedChatIndex: state.selectedChatIndex + }; + } + + case SETUP_NEW_DIRECT_CHAT_SUCCEEDED: { + const user = event.payload; + + const newChat: DirectChat = { + kind: "direct", + them: user.userId, + updatedDate: 0, + latestMessageId: 0, + readUpTo: 0, + missingMessages: [], + messages: [] + }; + + return { + ...state, + chats: [newChat, ...state.chats], + selectedChatIndex: 0 + }; + } + + default: + return state; + } +} + +function findDirectChatIndex(chats: Chat[], userId: UserId) : number { + return chats.findIndex(c => c.kind === "direct" && userIdsEqual(userId, c.them)); +} + +function findGroupChatIndex(chats: Chat[], chatId: ChatId) : number { + return chats.findIndex(c => c.kind === "group" && chatIdsEqual(chatId, c.chatId)); +} + +function sortMessages(left: Message, right: Message) : number { + if (left.kind === "unconfirmed") { + return right.kind === "unconfirmed" ? 0 : 1; + } else if (right.kind === "unconfirmed") { + return -1; + } + + return left.id - right.id; +} diff --git a/src/open_chat_assets/public/reducers/index.ts b/src/open_chat_assets/public/reducers/index.ts new file mode 100644 index 0000000000..bf4c8dc3ff --- /dev/null +++ b/src/open_chat_assets/public/reducers/index.ts @@ -0,0 +1,13 @@ +import { combineReducers } from "redux"; + +import chatsReducer from "./chatsReducer"; +import usersReducer from "./usersReducer"; + +const rootReducer = combineReducers({ + chatsState: chatsReducer, + usersState: usersReducer +}); + +export default rootReducer; + +export type RootState = ReturnType; diff --git a/src/open_chat_assets/public/reducers/usersReducer.ts b/src/open_chat_assets/public/reducers/usersReducer.ts new file mode 100644 index 0000000000..636f381fc5 --- /dev/null +++ b/src/open_chat_assets/public/reducers/usersReducer.ts @@ -0,0 +1,179 @@ +import { Option } from "../model/common"; +import { UserId, UserSummary } from "../model/users"; +import { userIdsEqual } from "../utils"; + +import { GET_ALL_CHATS_SUCCEEDED, GetAllChatsSucceededEvent } from "../actions/chats/getAllChats"; +import { SETUP_NEW_DIRECT_CHAT_SUCCEEDED, SetupNewDirectChatSucceededEvent } from "../actions/chats/setupNewDirectChat"; + +import { + GET_CURRENT_USER_FAILED, + GET_CURRENT_USER_SUCCEEDED, + GetCurrentUserFailedEvent, + GetCurrentUserRequestedEvent, + GetCurrentUserSucceededEvent +} from "../actions/users/getCurrentUser"; + +import { + GET_USERS_SUCCEEDED, + GetUsersFailedEvent, + GetUsersRequestedEvent, + GetUsersSucceededEvent +} from "../actions/users/getUsers"; + +import { + REGISTER_USER_FAILED_USER_EXISTS, + REGISTER_USER_FAILED_USERNAME_EXISTS, + REGISTER_USER_SUCCEEDED, + RegisterUserFailedUserExistsEvent, + RegisterUserFailedUsernameExistsEvent, + RegisterUserRequestedEvent, + RegisterUserSucceededEvent +} from "../actions/users/registerUser"; + +export type Event = + GetAllChatsSucceededEvent | + GetCurrentUserRequestedEvent | + GetCurrentUserSucceededEvent | + GetCurrentUserFailedEvent | + GetUsersRequestedEvent | + GetUsersSucceededEvent | + GetUsersFailedEvent | + RegisterUserRequestedEvent | + RegisterUserSucceededEvent | + RegisterUserFailedUserExistsEvent | + RegisterUserFailedUsernameExistsEvent | + SetupNewDirectChatSucceededEvent; + +type State = { + mustRegisterAsNewUser: boolean, + me: Option, + unknownUserIds: UserId[], + userDictionary: {} +} + +const initialState: State = { + mustRegisterAsNewUser: false, + me: null, + unknownUserIds: [], + userDictionary: {} +}; + +export default function(state: State = initialState, event: Event) : State { + switch (event.type) { + case GET_ALL_CHATS_SUCCEEDED: { + const chats = event.payload; + const unknownUserIds = [...state.unknownUserIds]; + const userDictionary: any = state.userDictionary; + + chats.forEach((c => { + if (c.kind === "direct") { + if (!userDictionary.hasOwnProperty(c.them.toString()) && + !unknownUserIds.find(u => userIdsEqual(u, c.them))) { + unknownUserIds.push(c.them); + } + } else { + c.participants.forEach((p: UserId) => { + if (!userDictionary.hasOwnProperty(p.toString()) && + !unknownUserIds.find(u => userIdsEqual(u, p)) ) { + unknownUserIds.push(p); + } + }) + } + })); + + return { + ...state, + unknownUserIds + }; + } + + case GET_CURRENT_USER_SUCCEEDED: { + alert("Hi " + event.payload.username + "!"); + + return { + ...state, + mustRegisterAsNewUser: false, + me: event.payload + }; + } + + case GET_CURRENT_USER_FAILED: { + return { + ...state, + mustRegisterAsNewUser: true, + me: null + }; + } + + case GET_USERS_SUCCEEDED: { + const users = event.payload; + const unknownUserIds: UserId[] = state.unknownUserIds.slice(); + const userDictionary: any = { ...state.userDictionary }; + + users.forEach(user => { + const index = state.unknownUserIds.findIndex(u => userIdsEqual(u, user.userId)); + if (index >= 0) { + unknownUserIds.splice(index, 1); + } + userDictionary[user.userId.toString()] = user; + }); + + return { + ...state, + unknownUserIds, + userDictionary + } + } + + case REGISTER_USER_SUCCEEDED: { + alert("Hi " + event.payload.username + "!"); + + return { + ...state, + mustRegisterAsNewUser: false, + me: event.payload + }; + } + + case REGISTER_USER_FAILED_USER_EXISTS: { + alert("You already have a user account"); + + return { + ...state, + mustRegisterAsNewUser: false + }; + } + + case REGISTER_USER_FAILED_USERNAME_EXISTS: { + alert("Username taken"); + + return { + ...state, + mustRegisterAsNewUser: true + }; + } + + case SETUP_NEW_DIRECT_CHAT_SUCCEEDED: { + const user = event.payload; + const unknownUserIds = [...state.unknownUserIds]; + const userDictionary: any = { ...state.userDictionary }; + + const index = unknownUserIds.findIndex(u => userIdsEqual(u, user.userId)); + + if (index >= 0) { + unknownUserIds.splice(index, 1); + } + + userDictionary[user.userId.toString()] = user; + + return { + ...state, + unknownUserIds, + userDictionary + }; + } + + default: + return state; + } +} diff --git a/src/open_chat_assets/public/services/chats/listChats.ts b/src/open_chat_assets/public/services/chats/listChats.ts index 688f8668d6..6e65f4a04b 100644 --- a/src/open_chat_assets/public/services/chats/listChats.ts +++ b/src/open_chat_assets/public/services/chats/listChats.ts @@ -1,8 +1,8 @@ import canister from "ic:canisters/chats"; -import { Chat, ChatId, DirectChat, GroupChat } from "../../model/chats"; -import {ConfirmedMessage, Message} from "../../model/messages"; -import {Option} from "../../model/common"; -import {convertToOption} from "../option"; +import { Chat, DirectChat, GroupChat } from "../../model/chats"; +import { Option } from "../../model/common"; +import { ConfirmedMessage, Message } from "../../model/messages"; +import { convertToOption } from "../option"; export default async function(unreadOnly: boolean) : Promise { let response = await canister.list_chats(unreadOnly); @@ -44,6 +44,7 @@ function convertToDirectChat(value: any) : DirectChat { updatedDate: latestMessage.timestamp, latestMessageId: latestMessage.id, readUpTo: latestMessage.id - value.unread, + missingMessages: [], messages: [{ kind: "confirmed", ...latestMessage }] } } @@ -64,6 +65,7 @@ function convertToGroupChat(value: any) : GroupChat participants: value.participants, latestMessageId: value.latest_message.id, readUpTo: value.latest_message.id - value.unread, + missingMessages: [], messages: messages }; } diff --git a/src/open_chat_assets/public/services/userMgmt/getCurrentUser.ts b/src/open_chat_assets/public/services/userMgmt/getCurrentUser.ts index 660087501e..52928edfe6 100644 --- a/src/open_chat_assets/public/services/userMgmt/getCurrentUser.ts +++ b/src/open_chat_assets/public/services/userMgmt/getCurrentUser.ts @@ -1,5 +1,5 @@ -import canister from "ic:canisters/chats"; -import {UserSummary} from "../../model/users"; +import canister from "ic:canisters/user_mgmt"; +import { UserSummary } from "../../model/users"; export default async function() : Promise { let response = await canister.get_current_user(); diff --git a/src/open_chat_assets/public/services/userMgmt/getUserId.ts b/src/open_chat_assets/public/services/userMgmt/getUserId.ts index 9e34c6ef3a..114dbe0a44 100644 --- a/src/open_chat_assets/public/services/userMgmt/getUserId.ts +++ b/src/open_chat_assets/public/services/userMgmt/getUserId.ts @@ -1,4 +1,4 @@ -import canister from "ic:canisters/chats"; +import canister from "ic:canisters/user_mgmt"; import {UserId} from "../../model/users"; export default async function(username: string) : Promise { diff --git a/src/open_chat_assets/public/services/userMgmt/getUsers.ts b/src/open_chat_assets/public/services/userMgmt/getUsers.ts index 5fabd889c6..59e98a0fe5 100644 --- a/src/open_chat_assets/public/services/userMgmt/getUsers.ts +++ b/src/open_chat_assets/public/services/userMgmt/getUsers.ts @@ -1,4 +1,4 @@ -import canister from "ic:canisters/chats"; +import canister from "ic:canisters/user_mgmt"; import {UserId, UserSummary} from "../../model/users"; import {Option} from "../../model/common"; import {convertFromOption} from "../option"; diff --git a/src/open_chat_assets/public/services/userMgmt/registerUser.ts b/src/open_chat_assets/public/services/userMgmt/registerUser.ts index 5f2e4bdffe..6b809e923b 100644 --- a/src/open_chat_assets/public/services/userMgmt/registerUser.ts +++ b/src/open_chat_assets/public/services/userMgmt/registerUser.ts @@ -1,4 +1,4 @@ -import canister from "ic:canisters/chats"; +import canister from "ic:canisters/user_mgmt"; import {UserSummary} from "../../model/users"; export default async function(username: string) : Promise { diff --git a/src/open_chat_assets/public/services/userMgmt/updateUsername.ts b/src/open_chat_assets/public/services/userMgmt/updateUsername.ts index b637c881db..f98bdd6399 100644 --- a/src/open_chat_assets/public/services/userMgmt/updateUsername.ts +++ b/src/open_chat_assets/public/services/userMgmt/updateUsername.ts @@ -1,4 +1,4 @@ -import canister from "ic:canisters/chats"; +import canister from "ic:canisters/user_mgmt"; export default async function(username: string) : Promise { let response = await canister.update_username(username); diff --git a/src/open_chat_assets/public/store.ts b/src/open_chat_assets/public/store.ts new file mode 100644 index 0000000000..ab88cc50a8 --- /dev/null +++ b/src/open_chat_assets/public/store.ts @@ -0,0 +1,20 @@ +import { createStore, applyMiddleware, compose } from "redux"; +import { composeWithDevTools } from "redux-devtools-extension"; +import thunk from "redux-thunk"; + +import rootReducer from "./reducers"; + +const initialState = {}; + +const middleware = [thunk]; + +const store = createStore( + rootReducer, + initialState, + compose( + applyMiddleware(...middleware), + composeWithDevTools() + ) +); + +export default store; diff --git a/src/open_chat_assets/public/utils.ts b/src/open_chat_assets/public/utils.ts new file mode 100644 index 0000000000..60a2971b35 --- /dev/null +++ b/src/open_chat_assets/public/utils.ts @@ -0,0 +1,12 @@ +import { ChatId } from "./model/chats"; +import { UserId } from "./model/users"; + +export function userIdsEqual(userId1: UserId, userId2: UserId) : boolean { + // TODO: Sort this! + return Boolean(userId1) && Boolean(userId2) && userId1.toString() === userId2.toString(); +} + +export function chatIdsEqual(chatId1: ChatId, chatId2: ChatId) : boolean { + // TODO: Sort this! + return Boolean(chatId1) && Boolean(chatId2) && (chatId1.toString() === chatId2.toString()); +} \ No newline at end of file