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 (
-
-
);
- }
}
-
-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 (
+
+ );
+}
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