Compare commits
12 commits
Author | SHA1 | Date | |
---|---|---|---|
ed2f74c021 | |||
0c79c5ca37 | |||
3e1e5b24b5 | |||
fa2e0cf7f0 | |||
04396259a0 | |||
deb55c2c6d | |||
f40ad116fe | |||
5a53e7069d | |||
c89d435b5a | |||
9eb850a969 | |||
5b782f998c | |||
e809c3f5d1 |
24 changed files with 2212 additions and 144 deletions
62
.drone.yml
Normal file
62
.drone.yml
Normal 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
12
.editorconfig
Normal 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
8
bridge/Dockerfile
Normal 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
3
bridge/config.yml
Normal file
|
@ -0,0 +1,3 @@
|
|||
allowedServers:
|
||||
- "195.201.123.169:16000"
|
||||
- "127.0.0.1:16000"
|
|
@ -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
40
bridge/go.sum
Normal 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=
|
158
bridge/main.go
158
bridge/main.go
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 |
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 |
57
frontend/src/components/ChannelList.tsx
Normal file
57
frontend/src/components/ChannelList.tsx
Normal 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
|
63
frontend/src/components/ConnectModal.tsx
Normal file
63
frontend/src/components/ConnectModal.tsx
Normal 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;
|
47
frontend/src/components/Header.tsx
Normal file
47
frontend/src/components/Header.tsx
Normal 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
|
19
frontend/src/components/MemberList.tsx
Normal file
19
frontend/src/components/MemberList.tsx
Normal 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
|
23
frontend/src/components/Message.tsx
Normal file
23
frontend/src/components/Message.tsx
Normal 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
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
188
frontend/src/middleware/chat.tsx
Normal file
188
frontend/src/middleware/chat.tsx
Normal 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;
|
116
frontend/src/slices/chat.tsx
Normal file
116
frontend/src/slices/chat.tsx
Normal 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
15
frontend/src/store.ts
Normal 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
1301
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue