Compare commits

...

12 commits
main ... dev

Author SHA1 Message Date
ed2f74c021
Fix drone
Some checks reported errors
continuous-integration/drone/push Build was killed
2022-08-10 18:20:13 +01:00
0c79c5ca37
Add Dockerfile
Some checks reported errors
continuous-integration/drone/push Build was killed
2022-08-10 18:08:46 +01:00
3e1e5b24b5
Add docker build
Some checks reported errors
continuous-integration/drone/push Build was killed
2022-08-10 18:01:03 +01:00
fa2e0cf7f0 Add CI pipeline
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-10 14:43:16 +01:00
04396259a0 Add connection window 2022-08-10 14:24:58 +01:00
deb55c2c6d handle more messages 2022-08-09 14:51:09 +01:00
f40ad116fe
Finish basic chat 2022-08-08 20:03:35 +01:00
5a53e7069d wip 2022-08-05 16:30:13 +01:00
c89d435b5a
Finish UI skeleton 2022-08-04 18:41:53 +01:00
9eb850a969 Mantine rewrite 2022-08-04 16:26:43 +01:00
5b782f998c
Start frontend 2022-08-03 20:53:43 +01:00
e809c3f5d1
Add bridge framework 2022-08-03 19:59:42 +01:00
24 changed files with 2212 additions and 144 deletions

62
.drone.yml Normal file
View file

@ -0,0 +1,62 @@
---
kind: pipeline
type: kubernetes
name: deploy
node_selector:
kubernetes.io/arch: amd64
steps:
- name: frontend-build
image: node:lts-alpine
commands:
- cd frontend
- yarn install
- yarn run build
depends_on: []
- name: frontend-deploy
image: plugins/netlify
settings:
token:
from_secret: netlify_token
site:
from_secret: netlify_site_id
path: ./frontend/dist
prod: true
depends_on:
- frontend-build
- name: bridge-publish-arm64
image: banzaicloud/drone-kaniko
settings:
username:
from_secret: gitea_user
password:
from_secret: gitea_token
registry: git.vh7.uk
repo: jakew/echo-web-bridge
context: ./bridge
tags: latest-arm64
platform: linux/arm64
depends_on: []
# - name: bridge-publish-amd64
# image: banzaicloud/drone-kaniko
# settings:
# username:
# from_secret: gitea_user
# password:
# from_secret: gitea_token
# registry: git.vh7.uk
# repo: git.vh7.uk/jakew/echo-web-bridge
# context: /drone/src/bridge
# dockerfile: /drone/src/bridge/Dockerfile
# tags: latest-amd64
# platform: linux/amd64
# depends_on:
# - bridge-build-arm64
trigger:
branch:
- main
- dev
event:
- push

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

8
bridge/Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM golang:1.18-alpine
WORKDIR /app
COPY . /app
RUN go mod download \
&& go build -o /echo-bridge \
&& cp config.yml /
EXPOSE 4000
CMD ["/echo-bridge"]

3
bridge/config.yml Normal file
View file

@ -0,0 +1,3 @@
allowedServers:
- "195.201.123.169:16000"
- "127.0.0.1:16000"

View file

@ -1,3 +1,19 @@
module git.vh7.uk/jakew/echo-web/bridge
go 1.18
require (
git.vh7.uk/jakew/echo-go v0.0.0-20220809134940-468833eae90f
github.com/gorilla/websocket v1.5.0
github.com/rs/zerolog v1.27.0
github.com/samber/lo v1.27.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/thoas/go-funk v0.9.2 // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 // indirect
)

40
bridge/go.sum Normal file
View file

@ -0,0 +1,40 @@
git.vh7.uk/jakew/echo-go v0.0.0-20220809112338-5bd7802455cb h1:wE2bgtd4F/dJKVmMkxqHjQs16vsrqo4TuNbLIM4QX28=
git.vh7.uk/jakew/echo-go v0.0.0-20220809112338-5bd7802455cb/go.mod h1:Q4YqOodoX+qYvfM0gSf78cCk0o7dkg1GLNYUFgB6qhU=
git.vh7.uk/jakew/echo-go v0.0.0-20220809134940-468833eae90f h1:8DDRGhtJoDVLoYNHkWgvlV1WkxATxO5d+4XBplCkoWI=
git.vh7.uk/jakew/echo-go v0.0.0-20220809134940-468833eae90f/go.mod h1:Q4YqOodoX+qYvfM0gSf78cCk0o7dkg1GLNYUFgB6qhU=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs=
github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U=
github.com/samber/lo v1.27.0 h1:GOyDWxsblvqYobqsmUuMddPa2/mMzkKyojlXol4+LaQ=
github.com/samber/lo v1.27.0/go.mod h1:it33p9UtPMS7z72fP4gw/EIfQB2eI8ke7GR2wc6+Rhg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/thoas/go-funk v0.9.2 h1:oKlNYv0AY5nyf9g+/GhMgS/UO2ces0QRdPKwkhY3VCk=
github.com/thoas/go-funk v0.9.2/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 h1:id054HUawV2/6IGm2IV8KZQjqtwAOo2CYlOToYqa0d0=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1 +1,159 @@
package main
import (
"encoding/json"
"io/ioutil"
"net/http"
"os"
echo "git.vh7.uk/jakew/echo-go"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"gopkg.in/yaml.v3"
)
type Config struct {
AllowedServers []string `yaml:"allowedServers" json:"allowedServers"`
}
type websocketHandler struct {
config Config
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func (h *websocketHandler) receiver(conn *websocket.Conn, client *echo.Client) {
defer conn.Close()
for {
msgs, err := client.Receive()
if err != nil {
log.Error().Err(err).Msg("failed to receive from server")
return
}
for _, msg := range msgs {
log.Info().Interface("msg", msg).Msg("received message")
bytes, err := json.Marshal(&msg)
if err != nil {
log.Error().Err(err).Msg("failed to marshal message")
}
conn.WriteMessage(websocket.TextMessage, bytes)
}
}
}
func (h *websocketHandler) socketHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Warn().Err(err).Msg("failed to upgrade connection")
return
}
defer conn.Close()
log.Info().Str("address", conn.RemoteAddr().String()).Msg("websocket connection opened")
server := r.URL.Query().Get("server")
username := r.URL.Query().Get("username")
password := r.URL.Query().Get("password")
if !lo.Contains(h.config.AllowedServers, server) {
_ = conn.WriteMessage(websocket.TextMessage, []byte("{\"error\": \"Server not allowed\"}"))
return
}
log.Info().Str("dest", server).Str("user", username).Msg("creating echo client")
client, err := echo.New(server, username)
if err != nil {
log.Error().Err(err).Msg("failed to create echo client")
_ = conn.WriteMessage(websocket.TextMessage, []byte("{\"error\": \"Failed to connect\"}"))
return
}
defer client.Disconnect()
err = client.HandshakeLoop("4.0.0", password)
if err != nil {
log.Error().Err(err).Msg("failed to handshake with server")
_ = conn.WriteMessage(websocket.TextMessage, []byte("{\"error\": \"Failed to connect\"}"))
return
}
go h.receiver(conn, client)
for {
messageType, message, err := conn.ReadMessage()
if err != nil {
log.Warn().Err(err).Msg("failed to receive websocket message")
return
}
if messageType == websocket.CloseMessage {
log.Debug().Msg("websocket closed")
break
} else if messageType == websocket.TextMessage {
var echoMessage echo.RawMessage
err = json.Unmarshal(message, &echoMessage)
if err != nil {
log.Warn().Err(err).Msg("failed to unmarshal message")
break
}
client.Send(echoMessage.MessageType, echoMessage.Data, echoMessage.SubType, []string{})
} else {
log.Warn().Int("type", messageType).Bytes("message", message).Msg("message type not implemented")
}
}
}
func (h *websocketHandler) configHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
bytes, err := json.Marshal(h.config)
if err != nil {
log.Error().Err(err).Msg("failed to marshal config")
w.WriteHeader(500)
return
}
_, err = w.Write(bytes)
if err != nil {
log.Error().Err(err).Msg("failed to write bytes to response")
w.WriteHeader(500)
return
}
}
func main() {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
log.Debug().Msg("loading config")
bytes, err := ioutil.ReadFile("config.yml")
if err != nil {
log.Fatal().Err(err).Msg("failed to load config file")
}
var config Config
err = yaml.Unmarshal(bytes, &config)
if err != nil {
log.Fatal().Err(err).Msg("failed to load config file")
}
handler := websocketHandler{
config: config,
}
http.HandleFunc("/", handler.socketHandler)
http.HandleFunc("/config", handler.configHandler)
log.Info().Msg("starting server")
err = http.ListenAndServe(":4000", nil)
if err != nil {
log.Fatal().Err(err).Msg("failed to start server")
}
}

View file

@ -2,9 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>Echo Web</title>
</head>
<body>
<div id="root"></div>

View file

@ -9,14 +9,26 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.10.0",
"@mantine/core": "^5.0.2",
"@mantine/dates": "^5.0.2",
"@mantine/hooks": "^5.0.2",
"@mantine/modals": "^5.0.2",
"@mantine/notifications": "^5.0.2",
"@reduxjs/toolkit": "^1.8.3",
"dayjs": "^1.11.4",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-redux": "^8.0.2",
"ws": "^8.8.1"
},
"devDependencies": {
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/ws": "^8.5.3",
"@vitejs/plugin-react": "^2.0.0",
"typescript": "^4.6.4",
"vite": "^3.0.0"
}
}
}

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,41 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View file

@ -1,33 +1,59 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import './App.css'
import { AppShell, Box, Button, Grid, Group, Input, Stack, TextInput } from "@mantine/core"
import { useState, createRef, useEffect } 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 [count, setCount] = useState(0)
const [opened, setOpened] = useState(false);
const [message, setMessage] = useState("");
const dispatch = useDispatch();
const isConnected = useSelector((state: RootState) => state.chat.isConnected);
const currentChannel = useSelector((state: RootState) => state.chat.currentChannel);
const messages = useSelector((state: RootState) => state.chat.messages);
const messagesEndRef = createRef<HTMLDivElement>();
const chatDisabled = currentChannel === null || !isConnected;
const sendMessage = () => {
dispatch(chatActions.sendMessage({ content: message }));
setMessage("");
}
useEffect(() => {
messagesEndRef.current?.scrollIntoView();
}, [messages]);
return (
<div className="App">
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" className="logo" alt="Vite logo" />
</a>
<a href="https://reactjs.org" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</div>
<AppShell
padding={0}
navbarOffsetBreakpoint="sm"
navbar={<ChannelList hidden={!opened} />}
header={<Header opened={opened} setOpened={setOpened} />}
aside={<MemberList />}
>
<Stack sx={{ height: "calc(100vh - 60px)", maxHeight: "calc(100vh - 60px)" }} spacing={0}>
<Stack sx={{ overflowY: "scroll", flexGrow: 1 }} p="md">
{messages.map((msg, i) => (
<Message key={`msg${i}`} message={msg} />
))}
<div ref={messagesEndRef} />
</Stack>
<Group p="md" sx={(theme) => ({ borderTop: `1px solid ${theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2]}` })}>
<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 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,57 @@
import { Box, Button, Navbar, NavLink, Stack } from "@mantine/core";
import { openModal } from "@mantine/modals";
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";
import ConnectModal from "./ConnectModal";
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>
{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 }}
onClick={connectClick}
loading={!isConnected && isConnecting}
>
{connectText}
</Button>
<Button variant="outline" onClick={() => openModal({ title: "Settings", children: <ConnectModal /> })} >
<Settings size={20} />
</Button>
</Button.Group>
</Navbar>
)
}
export default ChannelList

View file

@ -0,0 +1,63 @@
import { Button, Select, SelectItem, Text, TextInput } from "@mantine/core";
import { showNotification } from "@mantine/notifications";
import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
import { Check, CheckCircle, X, XCircle } from "react-feather";
import { useDispatch, useSelector } from "react-redux";
import { chatActions } from "../slices/chat";
import { RootState } from "../store";
function ConnectModal() {
const dispatch = useDispatch();
const stateBridgeAddress = useSelector((state: RootState) => state.chat.bridgeAddress);
const stateServerAddress = useSelector((state: RootState) => state.chat.serverAddress);
const stateUsername = useSelector((state: RootState) => state.chat.username);
const statePassword = useSelector((state: RootState) => state.chat.password);
const [bridgeAddress, setBridgeAddress] = useState<string>(stateBridgeAddress);
const [serverAddress, setServerAddress] = useState<string>(stateServerAddress || "");
const [credentials, setCredentials] = useState<{
username: string,
password: string
}>({
username: stateUsername,
password: statePassword || ""
});
const saveConnectionDetails = () => {
if (bridgeAddress.trim() === "" || serverAddress.trim() === "" || credentials.username.trim() === "") {
showNotification({
title: "Invalid Connection Parameters",
message: "One of the required connection parameters is empty or not valid.",
color: "red",
icon: <X />
});
return;
}
dispatch(chatActions.setConnectionParameters({
bridgeAddress,
serverAddress,
...credentials
}));
showNotification({
title: "Connecton Parameters Saved",
message: `Connecting to ${serverAddress} via ${bridgeAddress} as ${credentials.username}.`,
color: "green",
icon: <Check />
})
}
return (
<>
<Text size="md" mb="sm">Connection Settings</Text>
<TextInput label="Bridge URL" required value={bridgeAddress} onChange={(event) => setBridgeAddress(event.target.value)} />
<TextInput label="Server" required value={serverAddress} onChange={(event) => setServerAddress(event.target.value)} />
<TextInput label="Username" required value={credentials.username} onChange={(event) => setCredentials({ ...credentials, username: event.target.value })} />
<TextInput label="Password" value={credentials.password} onChange={(event) => setCredentials({ ...credentials, password: event.target.value })} />
<Button mt="sm" sx={{ float: "right" }} onClick={saveConnectionDetails}>Save</Button>
</>
);
}
export default ConnectModal;

View file

@ -0,0 +1,47 @@
import { Header as MHeader, Group, Text, MediaQuery, Burger, createStyles } from '@mantine/core';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
interface HeaderProps {
opened: boolean
setOpened: React.Dispatch<React.SetStateAction<boolean>>
}
const useStyles = createStyles((theme) => ({
title: {
[`@media (min-width: ${theme.breakpoints.sm}px)`]: {
width: "180px"
},
[`@media (min-width: ${theme.breakpoints.md}px)`]: {
width: "280px"
}
}
}))
function Header(props: HeaderProps) {
const { classes } = useStyles();
const channelName = useSelector((state: RootState) => state.chat.currentChannel);
return (
<MHeader height={60} p="md" sx={(theme) => ({
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.blue[6],
color: 'white'
})}>
<Group sx={{ height: "100%" }}>
<MediaQuery largerThan="sm" styles={{ display: 'none' }}>
<Burger
opened={props.opened}
onClick={() => props.setOpened((o) => !o)}
color="white"
size="sm" />
</MediaQuery>
<Text size="xl" my="auto" className={classes.title}>Echo Web</Text>
<MediaQuery smallerThan="sm" styles={{ display: 'none' }}>
<Text size="md" my="auto">{channelName}</Text>
</MediaQuery>
</Group>
</MHeader>
)
}
export default Header

View file

@ -0,0 +1,19 @@
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 }}>
{users.map((user) => (
<NavLink key={`user${user}`} label={user}/>
))}
</Aside>
</MediaQuery>
)
}
export default MemberList

View file

@ -0,0 +1,23 @@
import { Avatar, Box, Group, Stack, Text } from "@mantine/core"
import { ChatMessage } from "../middleware/chat"
interface MessageProps {
message: ChatMessage
}
function Message(props: MessageProps) {
return (
<Stack spacing={0} sx={{ flexGrow: 1, flexShrink: 1 }}>
<Text>
<Text component="span" color={props.message.color}>{props.message.author}</Text>
{' '}
<Text component="span" size="sm" my="auto" color="dimmed">at {props.message.date.toLocaleString()}</Text>
</Text>
<Text sx={{ whiteSpace: "pre-wrap" }}>
{props.message.content}
</Text>
</Stack>
)
}
export default Message

View file

@ -1,70 +1,3 @@
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
html, body, div#root {
height: 100%;
}

View file

@ -1,10 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { MantineProvider } from '@mantine/core';
import App from './App'
import './index.css'
import './index.css';
import { Provider } from 'react-redux';
import { store } from './store';
import { ModalsProvider } from '@mantine/modals';
import { NotificationsProvider } from '@mantine/notifications';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
<Provider store={store}>
<MantineProvider withGlobalStyles withNormalizeCSS>
<NotificationsProvider>
<ModalsProvider>
<App />
</ModalsProvider>
</NotificationsProvider>
</MantineProvider>
</Provider>
</React.StrictMode>
)

View file

@ -0,0 +1,188 @@
import { Middleware } from "@reduxjs/toolkit";
import chat, { chatActions } from "../slices/chat";
import { RootState } from "../store";
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",
ResUserlistUpdate = "userlistUpdate"
}
interface ServerMessage {
userid?: string
messagetype: MessageType;
subtype?: string;
data: any;
metadata?: string;
}
export interface ChatMessage {
content: string;
author: string;
color: string;
date: string;
}
const defaultMetadata = ['Unknown', '#ffffff', '0'];
const chatMiddleware: Middleware = (store) => {
let socket: WebSocket | null = null;
return (next) => (action) => {
if (chatActions.connect.match(action) && socket === null) {
const nextState: RootState = store.getState();
let connectionString = `${nextState.chat.bridgeAddress}?server=${nextState.chat.serverAddress}&username=${nextState.chat.username}`;
connectionString = connectionString.replace("http://", "ws://").replace("https://", "wss://");
if (nextState.chat.password && nextState.chat.password !== "") {
connectionString += `&password=${nextState.chat.password}`;
}
console.log(`Connecting to ${connectionString}...`);
socket = new WebSocket(connectionString);
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.setUsers({ users: JSON.parse(msg.data[2]).map((user: any) => {
return user[0];
})}));
break;
case MessageType.ResOutboundMessage:
let outboundMessageMetadata = msg.metadata || defaultMetadata;
store.dispatch(chatActions.recieveMessage({ message: {
author: outboundMessageMetadata[0],
content: msg.data,
color: outboundMessageMetadata[1],
date: (new Date(parseInt(outboundMessageMetadata[2]) * 1000)).toLocaleString()
}}))
break;
case MessageType.ResCommandData:
let commandDataMetadata = msg.metadata || defaultMetadata;
store.dispatch(chatActions.recieveMessage({ message: {
author: commandDataMetadata[0],
content: msg.data.join("\n"),
color: commandDataMetadata[1],
date: (new Date(parseInt(commandDataMetadata[2]) * 1000)).toLocaleString()
}}));
break;
case MessageType.ResChannelHistory:
store.dispatch(chatActions.addMessages({
messages: msg.data.map((message: any) => ({
author: message[0],
content: message[3],
color: message[4],
date: (new Date(parseInt(message[5]) * 1000)).toLocaleString()
}))
}))
break;
case MessageType.ResChannelUpdate:
const [user, fromChannel, toChannel] = msg.data;
store.dispatch(chatActions.updateUsers({
user,
fromChannel,
toChannel
}));
break;
case MessageType.ResConnectionTerminated:
store.dispatch(chatActions.recieveMessage({ message: {
author: "Server",
content: msg.data,
color: '#ff0000',
date: (new Date()).toLocaleString()
}}));
socket?.close();
break;
case MessageType.ResUserlistUpdate:
break;
default:
store.dispatch(chatActions.recieveMessage({ message: {
author: 'Server',
content: JSON.stringify(msg),
color: '#ff0000',
date: (new Date()).toLocaleString()
}}));
}
}
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

@ -0,0 +1,116 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ChatMessage } from "../middleware/chat";
export interface ChatState {
bridgeAddress: string;
serverAddress: string;
username: string;
password?: string;
messages: ChatMessage[];
users: string[];
channels: string[];
motd: string | null;
isEstablishingConnection: boolean;
isConnected: boolean;
currentChannel: string | null;
}
const initialState: ChatState = {
bridgeAddress: "http://127.0.0.1:4000",
serverAddress: "127.0.0.1:16000",
username: "echoweb",
messages: [],
users: [],
channels: [],
motd: null,
isEstablishingConnection: false,
isConnected: false,
currentChannel: null
};
const chatSlice = createSlice({
name: "chat",
initialState,
reducers: {
connect: (state) => {
state.isConnected = false;
state.isEstablishingConnection = true;
},
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: 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;
},
setUsers: (state, action: PayloadAction<{
users: string[]
}>) => {
state.users = action.payload.users;
},
updateUsers: (state, action: PayloadAction<{
user: string,
fromChannel: string,
toChannel: string
}>) => {
if (action.payload.toChannel === state.currentChannel) {
if (!state.users.includes(action.payload.user)) {
state.users.push(action.payload.user);
}
} else {
state.users = state.users.filter((user) => user !== action.payload.user);
}
},
changeChannel: (state, action: PayloadAction<{
channel: string
}>) => {
state.currentChannel = action.payload.channel;
state.messages = [];
},
addMessages: (state, action: PayloadAction<{
messages: ChatMessage[]
}>) => {
state.messages.push(...action.payload.messages);
},
sendMessage: (state, action: PayloadAction<{
content: string
}>) => {},
setConnectionParameters: (state, action: PayloadAction<{
bridgeAddress: string,
serverAddress: string,
username: string,
password?: string
}>) => {
state.bridgeAddress = action.payload.bridgeAddress;
state.serverAddress = action.payload.serverAddress;
state.username = action.payload.username;
state.password = action.payload.password;
}
}
});
export const chatActions = chatSlice.actions;
export default chatSlice.reducer;

15
frontend/src/store.ts Normal file
View file

@ -0,0 +1,15 @@
import { configureStore } from '@reduxjs/toolkit';
import chatMiddleware from './middleware/chat';
import chatSlice from './slices/chat';
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

1301
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load diff