Finish basic chat

This commit is contained in:
Jake Walker 2022-08-08 20:03:35 +01:00
parent 5a53e7069d
commit f40ad116fe
Signed by: jakew
GPG key ID: 2B83DC56C147243B
9 changed files with 281 additions and 48 deletions

View file

@ -1,2 +1,2 @@
allowedServers:
- "195.201.123.169:16000"
- "127.0.0.1:16000"

View file

@ -1,12 +1,26 @@
import { AppShell, Box, Button, Grid, Group, Input, Stack } from "@mantine/core"
import { AppShell, Box, Button, Grid, Group, Input, Stack, TextInput } from "@mantine/core"
import { useState } from "react"
import { useDispatch, useSelector } from "react-redux"
import ChannelList from "./components/ChannelList"
import Header from "./components/Header"
import MemberList from "./components/MemberList"
import Message from "./components/Message"
import { chatActions } from "./slices/chat"
import { RootState } from "./store"
function App() {
const [opened, setOpened] = useState(false);
const [message, setMessage] = useState("");
const dispatch = useDispatch();
const currentChannel = useSelector((state: RootState) => state.chat.currentChannel);
const messages = useSelector((state: RootState) => state.chat.messages);
const chatDisabled = currentChannel === null;
const sendMessage = () => {
dispatch(chatActions.sendMessage({ content: message }));
setMessage("");
}
return (
<AppShell
@ -16,15 +30,19 @@ function App() {
header={<Header opened={opened} setOpened={setOpened} />}
aside={<MemberList />}
>
<Stack sx={{ maxHeight: "calc(100vh - 60px)" }} spacing={0}>
<Stack sx={{ overflowY: "auto", flexGrow: 1 }} p="md">
{[...Array(10)].map((e, i) => (
<Message />
<Stack sx={{ height: "calc(100vh - 60px)" }} spacing={0}>
<Stack sx={{ overflowY: "auto", flexGrow: 1, flexShrink: 0 }} p="md">
{messages.map((msg, i) => (
<Message key={`msg${i}`} message={msg} />
))}
</Stack>
<Group p="md" sx={(theme) => ({ borderTop: `1px solid ${theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2]}`})}>
<Input sx={{ flexGrow: 1 }} placeholder="Message" />
<Button>Send</Button>
<Group p="md" sx={(theme) => ({ borderTop: `1px solid ${theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2]}`, flexGrow: 0, flexShrink: 0 })}>
<TextInput sx={{ flexGrow: 1 }} placeholder="Message" disabled={chatDisabled} value={message} onChange={(event) => setMessage(event.target.value)} onKeyUp={(event) => {
if (event.key === "Enter") {
sendMessage();
}
}}/>
<Button disabled={chatDisabled} onClick={() => sendMessage()}>Send</Button>
</Group>
</Stack>
</AppShell>

View file

@ -1,18 +1,49 @@
import { ActionIcon, Box, Button, Group, Navbar, NavLink, Stack } from "@mantine/core";
import { Box, Button, Navbar, NavLink, Stack } from "@mantine/core";
import { Root } from "react-dom/client";
import { Hash, Settings } from "react-feather";
import { useDispatch, useSelector } from "react-redux";
import { chatActions } from "../slices/chat";
import { RootState } from "../store";
function ChannelList(props: { hidden: boolean }) {
const dispatch = useDispatch();
const isConnected = useSelector((state: RootState) => state.chat.isConnected);
const isConnecting = useSelector((state: RootState) => state.chat.isEstablishingConnection);
const channels = useSelector((state: RootState) => state.chat.channels);
const currentChannel = useSelector((state: RootState) => state.chat.currentChannel);
const connectText = isConnected ? 'Disconnect' : 'Connect';
const connectClick = () => {
if (isConnected) {
dispatch(chatActions.disconnect());
} else {
dispatch(chatActions.connect());
}
}
return (
<Navbar width={{ base: '100%', sm: 200, md: 300 }} p="sm" hidden={props.hidden} hiddenBreakpoint="sm">
<Stack sx={{ flexGrow: 1 }}>
<Box>
{[...Array(4)].map((e, i) => (
<NavLink label={`Channel ${i+1}`} icon={<Hash />}/>
{channels.map((channel) => (
<NavLink
key={`channel_${channel}`}
label={channel}
icon={<Hash />}
active={channel === currentChannel}
onClick={() => dispatch(chatActions.changeChannel({ channel }))}
/>
))}
</Box>
</Stack>
<Button.Group>
<Button sx={{ flexGrow: 1 }}>Connect</Button>
<Button
sx={{ flexGrow: 1 }}
onClick={connectClick}
loading={!isConnected && isConnecting}
>
{connectText}
</Button>
<Button variant="outline">
<Settings size={20} />
</Button>

View file

@ -1,11 +1,15 @@
import { Aside, MediaQuery, Navbar, NavLink } from "@mantine/core"
import { useSelector } from "react-redux"
import { RootState } from "../store"
function MemberList() {
const users = useSelector((state: RootState) => state.chat.users);
return (
<MediaQuery smallerThan="sm" styles={{ display: 'none' }}>
<Aside p="sm" hiddenBreakpoint="sm" width={{ sm: 200, lg: 300 }}>
{[...Array(4)].map((e, i) => (
<NavLink label={`Person ${i+1}`}/>
{users.map((user) => (
<NavLink key={`user${user}`} label={user}/>
))}
</Aside>
</MediaQuery>

View file

@ -1,17 +1,22 @@
import { Avatar, Box, Group, Stack, Text } from "@mantine/core"
import { ChatMessage } from "../middleware/chat"
function Message() {
interface MessageProps {
message: ChatMessage
}
function Message(props: MessageProps) {
return (
<Group sx={{ flexWrap: "nowrap" }}>
<Avatar color="cyan" radius="xl" sx={{ flexGrow: 0, flexShrink: 0, marginBottom: "auto" }}>LS</Avatar>
<Avatar color={props.message.color} radius="xl" sx={{ flexGrow: 0, flexShrink: 0, marginBottom: "auto" }}>LS</Avatar>
<Stack spacing="xs" sx={{ flexGrow: 1, flexShrink: 1 }}>
<Text>
Author
{props.message.author}
{' '}
<Text component="span" size="sm" my="auto" color="dimmed">at 4:13pm</Text>
<Text component="span" size="sm" my="auto" color="dimmed">at {props.message.date}</Text>
</Text>
<Text>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus nemo nisi saepe quasi eaque laudantium autem aliquid eligendi, nihil aut id sunt iusto nulla. Modi ut provident obcaecati quibusdam veritatis?
{props.message.content}
</Text>
</Stack>
</Group>

View file

@ -4,7 +4,7 @@ import { MantineProvider } from '@mantine/core';
import App from './App'
import './index.css';
import { Provider } from 'react-redux';
import store from './store';
import { store } from './store';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>

View file

@ -1,24 +1,147 @@
import { Middleware } from "@reduxjs/toolkit";
import WebSocket from 'ws';
import { chatActions } from "../slices/chat";
import chat, { chatActions } from "../slices/chat";
const chatMiddleware: Middleware = (store) => (next) => (action) => {
if (!chatActions.startConnecting.match(action)) {
return next(action);
enum MessageType {
ReqServerInfo = "serverInfoRequest",
ReqClientSecret = "clientSecret",
ReqConnection = "connectionRequest",
ReqDisconnect = "disconnect",
ReqInfo = "requestInfo",
ReqUserMessage = "userMessage",
ReqChangeChannel = "changeChannel",
ReqHistory = "historyRequest",
ReqLeaveChannel = "leaveChannel",
ResServerData = "serverData",
ResClientSecret = "gotSecret",
ResConnectionAccepted = "CRAccepted",
ResConnectionDenied = "CRDenied",
ResOutboundMessage = "outboundMessage",
ResConnectionTerminated = "connectionTerminated",
ResChannelUpdate = "channelUpdate",
ResCommandData = "commandData",
ResChannelHistory = "channelHistory",
ResErrorOccurred = "errorOccured",
ResAdditionalHistory = "additionalHistory"
}
const socket = new WebSocket("ws://127.0.0.1:4000?server=195.201.123.169:16000&username=jake");
interface ServerMessage {
userid?: string
messagetype: MessageType;
subtype?: string;
data: any;
metadata?: string;
}
socket.on('connect', () => {
console.log('Connected to WebSocket!');
store.dispatch(chatActions.connectionEstablished());
});
export interface ChatMessage {
content: string;
author: string;
color: string;
date: number;
}
socket.on('message', (message: any) => {
store.dispatch(chatActions.recieveMessage({ message }))
});
const defaultMetadata = ['Unknown', '#ffffff', '0'];
const chatMiddleware: Middleware = (store) => {
let socket: WebSocket | null = null;
return (next) => (action) => {
if (chatActions.connect.match(action) && socket === null) {
socket = new WebSocket("ws://127.0.0.1:4000?server=127.0.0.1:16000&username=jake&password=password");
socket.onopen = () => {
store.dispatch(chatActions.connected());
socket?.send(JSON.stringify({
messagetype: MessageType.ReqInfo
}));
}
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
const msg: ServerMessage = {
userid: data.userid || '0',
messagetype: data.messagetype,
subtype: data.subtype,
data: "",
metadata: "",
};
try {
msg.data = JSON.parse(data.data);
} catch (SyntaxError) {
msg.data = data.data;
}
try {
msg.metadata = JSON.parse(data.metadata);
} catch (SyntaxError) {
msg.metadata = data.metadata;
}
console.log(msg);
switch (msg.messagetype) {
case MessageType.ResServerData:
store.dispatch(chatActions.updateChannels({ channels: JSON.parse(msg.data[0]) }));
store.dispatch(chatActions.updateMotd({ motd: msg.data[1] }));
store.dispatch(chatActions.updateUsers({ users: JSON.parse(msg.data[2]).map((user) => {
return user[0];
})}));
break;
case MessageType.ResOutboundMessage:
let metadata = msg.metadata || defaultMetadata;
store.dispatch(chatActions.recieveMessage({ message: {
author: metadata[0],
content: msg.data,
color: metadata[1],
date: 0
}}))
break;
case MessageType.ResCommandData:
metadata = msg.metadata || defaultMetadata;
store.dispatch(chatActions.recieveMessage({ message: {
author: metadata[0],
content: msg.data,
color: metadata[1],
date: 0
}}));
break;
default:
store.dispatch(chatActions.recieveMessage({ message: {
author: 'Server',
content: JSON.stringify(msg),
color: '#ff0000',
date: 0
}}));
}
}
socket.onclose = () => {
store.dispatch(chatActions.disconnected());
}
socket.onerror = (event) => {
console.error('ws error', event);
}
} else if (chatActions.disconnect.match(action) && socket !== null) {
socket.close();
socket = null;
} else if (chatActions.changeChannel.match(action) && socket !== null) {
const changeChannelMessage: ServerMessage = {
messagetype: MessageType.ReqChangeChannel,
data: action.payload.channel
}
socket.send(JSON.stringify(changeChannelMessage));
} else if (chatActions.sendMessage.match(action) && socket !== null) {
const sendMessage: ServerMessage = {
messagetype: MessageType.ReqUserMessage,
data: action.payload.content
}
socket.send(JSON.stringify(sendMessage));
}
next(action);
}
};
export default chatMiddleware;

View file

@ -1,36 +1,78 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ChatMessage } from "../middleware/chat";
export interface ChatState {
messages: any[];
messages: ChatMessage[];
users: string[];
channels: string[];
motd: string | null;
isEstablishingConnection: boolean;
isConnected: boolean;
currentChannel: string | null;
}
const initialState: ChatState = {
messages: [],
users: [],
channels: [],
motd: null,
isEstablishingConnection: false,
isConnected: false
isConnected: false,
currentChannel: null
};
const chatSlice = createSlice({
name: "chat",
initialState,
reducers: {
startConnecting: (state) => {
connect: (state) => {
state.isConnected = false;
state.isEstablishingConnection = true;
},
connectionEstablished: (state) => {
connected: (state) => {
state.isConnected = true;
state.isEstablishingConnection = true;
},
disconnect: (state) => {
state.isConnected = false;
state.isEstablishingConnection = true;
},
disconnected: (state) => {
state.isConnected = false;
state.isEstablishingConnection = false;
},
recieveMessage: (state, action: PayloadAction<{
message: any
message: ChatMessage
}>) => {
state.messages.push(action.payload.message);
}
},
updateChannels: (state, action: PayloadAction<{
channels: string[]
}>) => {
state.channels = action.payload.channels;
},
updateMotd: (state, action: PayloadAction<{
motd: string
}>) => {
state.motd = action.payload.motd;
},
updateUsers: (state, action: PayloadAction<{
users: string[]
}>) => {
state.users = action.payload.users;
},
changeChannel: (state, action: PayloadAction<{
channel: string
}>) => {
state.currentChannel = action.payload.channel;
state.messages = [];
},
sendMessage: (state, action: PayloadAction<{
content: string
}>) => {}
}
});
export const chatActions = chatSlice.actions;
export default chatSlice;
export default chatSlice.reducer;

View file

@ -1,5 +1,15 @@
import { configureStore } from '@reduxjs/toolkit';
import chatMiddleware from './middleware/chat';
import chatSlice from './slices/chat';
export default configureStore({
reducer: {}
export const store = configureStore({
reducer: {
chat: chatSlice
},
middleware: (getDefaultMiddleware) => {
return getDefaultMiddleware().concat([chatMiddleware])
}
});
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch